Kalau kamu baru mulai belajar Laravel, pasti sudah sering lihat session() atau Session:: muncul di berbagai tutorial. Tapi jarang ada yang benar-benar jelasin: apa itu session, bagaimana cara kerjanya di balik layar, dan kapan harus pakai session vs cache?
Di artikel ini kita bahas dari awal — dengan analogi dulu, baru masuk ke kode.
🤔 Masalah yang Session Selesaikan
HTTP itu stateless. Artinya, setiap request yang masuk ke server dianggap orang asing — server tidak ingat siapa kamu, tidak ingat kamu sudah login, tidak ingat isi keranjang belanja kamu.
Bayangin kamu belanja di supermarket. Kamu ambil barang, taruh di keranjang, lalu pergi ke lorong berikutnya. Tapi begitu kamu balik, kasirnya tidak ingat siapa kamu — dan keranjang belanja kamu sudah kosong lagi.
Itulah HTTP tanpa session.
Session adalah cara Laravel “mengingat” siapa kamu di antara satu request ke request berikutnya. Laravel menyimpan data sementara yang dikaitkan dengan browser kamu — selama sesi berlangsung.
🧭 Cara Kerja Session di Balik Layar
Ini yang terjadi setiap kali kamu menggunakan session:
Browser Server (Laravel)
│ │
│──── Request + Cookie (session_id) ──▶│
│ │── Cari data session berdasarkan ID
│ │── Baca / tulis data
│◀─── Response + Cookie (session_id) ──│
│ │
- Saat pertama kali visit, Laravel membuat session ID unik — berupa string acak panjang.
- Session ID ini disimpan di cookie di browser kamu.
- Data session yang sebenarnya (isi keranjang, status login, dll) disimpan di server — bisa di file, database, Redis, tergantung driver yang dipakai.
- Di request berikutnya, browser otomatis kirim cookie itu, Laravel baca session ID-nya, lalu ambil data yang sesuai.
Jadi yang ada di browser kamu hanya ID-nya saja — datanya tetap di server. Ini yang bikin session relatif aman.
📦 Struktur Session di Laravel
Konfigurasi session ada di dua tempat:
📁 config/session.php — pengaturan utama session
<?php
return [
// Driver yang dipakai — file, cookie, database, redis, memcached, array
'driver' => env('SESSION_DRIVER', 'file'),
// Berapa lama session bertahan (dalam menit)
// Default 120 menit = 2 jam tidak aktif → session hangus
'lifetime' => env('SESSION_LIFETIME', 120),
// Session ikut hangus saat browser ditutup?
'expire_on_close' => false,
// Nama cookie yang dikirim ke browser
'cookie' => env('SESSION_COOKIE', 'laravel_session'),
// Domain yang boleh baca cookie ini
// null = hanya domain yang sama
'domain' => env('SESSION_DOMAIN', null),
];
📁 .env — nilai aktualnya
SESSION_DRIVER=file
SESSION_LIFETIME=120
🗂️ Driver Session — Pilih yang Mana?
Driver adalah tempat Laravel menyimpan data session. Masing-masing punya karakter yang berbeda.
1. file (Default)
Data session disimpan sebagai file teks di folder storage/framework/sessions/.
storage/
└── framework/
└── sessions/
├── a1b2c3d4e5f6... ← ini satu session (nama file = session ID)
├── x9y8z7w6v5u4...
└── ...
Kalau kamu buka salah satu filenya, isinya kira-kira seperti ini (terenkripsi):
a:3:{s:6:"_token";s:40:"abc123...";s:4:"cart";a:2:{...};s:8:"_flash";a:2:{...}}
Cocok untuk: development, project kecil, server tunggal.
Tidak cocok untuk: production dengan banyak traffic atau multiple server — karena file session hanya ada di satu server.
// .env
SESSION_DRIVER=file
2. database
Data session disimpan di tabel database. Harus buat tabelnya dulu:
php artisan session:table
php artisan migrate
Perintah di atas akan membuat tabel sessions dengan struktur seperti ini:
sessions
├── id ← session ID (primary key)
├── user_id ← null kalau belum login, diisi kalau sudah login
├── ip_address ← IP address pengunjung
├── user_agent ← info browser pengunjung
├── payload ← data session (terenkripsi)
└── last_activity ← timestamp terakhir aktif
Cocok untuk: production dengan server tunggal, atau kalau kamu butuh lihat data session langsung dari database (untuk debugging atau admin panel).
Tidak cocok untuk: traffic sangat tinggi — setiap request baca/tulis ke database bisa jadi bottleneck.
// .env
SESSION_DRIVER=database
3. redis
Data session disimpan di Redis — database in-memory yang sangat cepat.
// .env
SESSION_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Cocok untuk: production dengan traffic tinggi, atau aplikasi yang pakai multiple server (karena Redis bisa diakses dari server manapun).
Tidak cocok untuk: kalau belum ada infrastruktur Redis — setup-nya butuh langkah tambahan.
4. cookie
Data session disimpan langsung di cookie browser — tidak ada yang disimpan di server.
Cocok untuk: sangat jarang dipakai karena ada batasan ukuran cookie (4KB) dan semua data ada di sisi client.
Perbandingan Singkat
| Driver | Kecepatan | Cocok untuk | Butuh Setup Tambahan |
|---|---|---|---|
file | Sedang | Development, project kecil | ❌ |
database | Sedang | Production server tunggal | ✅ (migrate) |
redis | Sangat cepat | Production traffic tinggi | ✅ (install Redis) |
cookie | Cepat | Jarang dipakai | ❌ |
✍️ Cara Pakai Session di Laravel
Menyimpan Data
// Cara 1: helper function
session(['cart_count' => 3]);
// Cara 2: via Request object (di dalam Controller)
$request->session()->put('cart_count', 3);
// Cara 3: Facade
use Illuminate\Support\Facades\Session;
Session::put('cart_count', 3);
Membaca Data
// Ambil nilai, dengan default kalau key tidak ada
$count = session('cart_count', 0);
// Cara lain
$count = $request->session()->get('cart_count', 0);
Menghapus Data
// Hapus satu key
session()->forget('cart_count');
// Hapus semua data session
session()->flush();
Flash Data — Data yang Otomatis Hilang Setelah Dibaca
Ini fitur yang sangat berguna untuk pesan sukses/error setelah redirect:
// Di Controller: simpan pesan
session()->flash('success', 'Produk berhasil ditambahkan ke keranjang!');
return redirect()->route('cart.index');
// Di View (Blade): tampilkan pesan
@if (session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
📌 Flash data otomatis hilang setelah request berikutnya selesai — jadi pesan sukses tidak akan terus muncul kalau user refresh halaman.
🛒 Contoh Nyata — Keranjang Belanja
Ini contoh penggunaan session yang paling umum di e-commerce:
📁 app/Http/Controllers/CartController.php
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class CartController extends Controller
{
// Tambah produk ke keranjang
public function add(Request $request, Product $product): \Illuminate\Http\RedirectResponse
{
// Ambil isi keranjang saat ini, default array kosong
$cart = session('cart', []);
$productId = $product->id;
if (isset($cart[$productId])) {
// Kalau sudah ada di keranjang, tambah quantity-nya
$cart[$productId]['quantity']++;
} else {
// Kalau belum ada, masukkan sebagai item baru
$cart[$productId] = [
'name' => $product->name,
'price' => $product->price,
'quantity' => 1,
];
}
// Simpan kembali ke session
session(['cart' => $cart]);
session()->flash('success', "{$product->name} ditambahkan ke keranjang.");
return redirect()->back();
}
// Lihat isi keranjang
public function index(): \Illuminate\View\View
{
$cart = session('cart', []);
$total = collect($cart)->sum(fn ($item) => $item['price'] * $item['quantity']);
return view('cart.index', compact('cart', 'total'));
}
// Hapus satu produk dari keranjang
public function remove(int $productId): \Illuminate\Http\RedirectResponse
{
$cart = session('cart', []);
unset($cart[$productId]);
session(['cart' => $cart]);
session()->flash('success', 'Produk dihapus dari keranjang.');
return redirect()->back();
}
// Kosongkan keranjang (setelah checkout)
public function clear(): \Illuminate\Http\RedirectResponse
{
session()->forget('cart');
return redirect()->route('home');
}
}
🆚 Session vs Cache — Apa Bedanya?
Ini yang sering bikin bingung karena keduanya sama-sama “menyimpan data sementara”. Tapi tujuannya berbeda:
Session
- Data per pengguna — setiap pengunjung punya data session sendiri.
- Terikat pada satu browser/pengguna tertentu.
- Contoh: isi keranjang belanja, status login, pesan flash.
// Ini hanya ada untuk user yang sedang login di browser ini
session(['cart' => $cartData]);
Cache
- Data bersama — semua pengguna bisa membaca data yang sama.
- Dipakai untuk menyimpan hasil komputasi yang mahal agar tidak perlu dihitung ulang.
- Contoh: daftar produk terlaris, hasil query yang berat, konfigurasi dari database.
// Ini dibaca oleh SEMUA pengunjung website
$topProducts = Cache::remember('top_products', 3600, function () {
return Product::orderBy('sold_count', 'desc')->take(10)->get();
});
Analogi Mudahnya
Bayangkan kamu di sebuah restoran:
Session = meja kamu sendiri. Pesanan kamu, minuman kamu, tagihan kamu — hanya ada di meja kamu. Pengunjung lain tidak bisa lihat atau sentuh.
Cache = papan menu di dinding. Semua pengunjung baca menu yang sama. Kalau menu berubah, papannya diganti — dan semua orang langsung lihat menu baru.
Perbandingan
| Session | Cache | |
|---|---|---|
| Data untuk siapa | Satu pengguna | Semua pengguna |
| Tujuan | Menyimpan state pengguna | Menyimpan data yang mahal dihitung |
| Contoh | Keranjang belanja, login | Query berat, API response |
| Hangus saat | Logout / timeout | TTL habis |
💡 Ingat!
Gunakan session kalau data yang disimpan berbeda untuk tiap pengguna — isi keranjang, status login, preferensi tampilan.
Gunakan cache kalau data yang disimpan sama untuk semua pengguna — hasil query berat, data produk yang jarang berubah, response dari API eksternal.
Salah satunya adalah tentang siapa yang punya datanya. Session punya pemilik. Cache tidak.
Kalau blogmu lagi sepi, tidak ada yang perlu dioptimasi. Tapi begitu ramai — ratusan orang buka halaman yang sama dalam waktu bersamaan — tiba-tiba kamu sadar: setiap request menjalankan query yang sama, memproses data yang sama, dan menghasilkan output yang sama persis.
Itu pemborosan yang tidak perlu.
Cache adalah cara menyimpan hasil kerja yang sudah selesai, supaya tidak perlu dikerjakan ulang. Artikel ini akan bahas cara berpikir tentang cache, driver yang tersedia di Laravel, semua strategi caching yang relevan, dan jebakan-jebakan yang sering membuat pemula salah langkah.
🤔 Masalah yang Cache Selesaikan
Tanpa cache, setiap kali ada yang buka halaman artikel populer di blog kita, ini yang terjadi:
User buka /articles
→ Laravel terima request
→ Query ke database: ambil 10 artikel terbaru + jumlah komentar + data penulis
→ PHP proses hasil query
→ Render response
→ Kirim ke browser
Untuk 1 user: tidak masalah. Untuk 500 user dalam satu menit yang semua buka halaman yang sama: database kamu ketuk 500 kali untuk query yang hasilnya identik.
Cache memotong siklus itu:
Request pertama → query DB → simpan hasil → kirim ke user
Request ke-2 → ambil dari cache → kirim ke user ← tidak ada query DB
Request ke-3 → ambil dari cache → kirim ke user ← tidak ada query DB
...sampai cache kedaluwarsa...
Request ke-501 → query DB lagi → perbarui cache → kirim ke user
Kuncinya ada di pertanyaan ini: seberapa sering data ini berubah, dan seberapa mahal biaya mengambilnya? Kalau data jarang berubah tapi mahal diambil — itu kandidat cache yang bagus.
🗄️ Driver — “Tempat Menyimpan Cache”
Laravel mendukung beberapa driver cache. Pilihan driver menentukan di mana data cache disimpan secara fisik.
File — Default, Tanpa Setup
# .env
CACHE_STORE=file
Cache disimpan sebagai file di storage/framework/cache/. Tidak butuh software tambahan — langsung jalan di instalasi Laravel baru.
// Ini langsung jalan tanpa konfigurasi apapun
Cache::put('articles.featured', $articles, now()->addHour());
$articles = Cache::get('articles.featured');
Cocok untuk: development lokal, aplikasi kecil dengan traffic rendah, atau ketika belum ada infrastruktur Redis/Memcached.
Tidak cocok untuk: aplikasi yang jalan di beberapa server sekaligus (file cache tidak di-share antar server), atau traffic tinggi (banyak file kecil = banyak disk I/O).
Redis — Pilihan Produksi
# .env
CACHE_STORE=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Redis menyimpan data di memori — jauh lebih cepat dari disk. Selain itu, Redis mendukung banyak tipe data dan punya fitur seperti pub/sub dan atomic operations yang berguna untuk cache lock.
# Install driver PHP untuk Redis
composer require predis/predis
# atau pakai ekstensi PHP: pecl install redis
Cocok untuk: hampir semua aplikasi produksi. Terutama ketika aplikasi butuh session, cache, dan queue di infrastruktur yang sama.
Memcached — Alternatif Redis
# .env
CACHE_STORE=memcached
MEMCACHED_HOST=127.0.0.1
Mirip Redis — berbasis memori, sangat cepat. Lebih sederhana dari Redis karena hanya fokus pada caching. Pilih Redis kalau sudah ada di infrastrukturmu, pilih Memcached kalau tim sudah familiar dengannya.
Database — Cache di Tabel SQL
# .env
CACHE_STORE=database
Cache disimpan di tabel cache di database utama. Butuh satu langkah setup:
php artisan cache:table # generate migration
php artisan migrate
Cocok untuk: ketika tidak ada Redis/Memcached tapi butuh cache yang di-share antar server. Lebih lambat dari Redis karena masih query SQL, tapi lebih baik dari file untuk multi-server setup.
Array — Untuk Testing
Cache hanya hidup di memori PHP selama satu request. Tidak persistent, tidak menyentuh Redis atau file sungguhan — sempurna untuk unit test.
# .env.testing
CACHE_STORE=array
⚙️ Konfigurasi Dasar
📁 config/cache.php
<?php
return [
// Driver default yang dipakai kalau tidak disebutkan eksplisit
'default' => env('CACHE_STORE', 'file'),
'stores' => [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
],
'redis' => [
'driver' => 'redis',
'connection' => 'cache', // → lihat config/database.php bagian redis
'lock_connection' => 'default',
],
'database' => [
'driver' => 'database',
'table' => 'cache',
'connection' => null,
],
],
// Prefix untuk semua key — berguna kalau Redis dipakai bersama aplikasi lain
'prefix' => env('CACHE_PREFIX', 'blog_cache'),
];
📌 Satu aplikasi bisa pakai lebih dari satu driver sekaligus — misalnya Redis untuk data yang sering diakses, dan database untuk data yang perlu persist lebih lama. Kamu tinggal sebut driver mana yang mau dipakai saat memanggil cache.
🔧 Operasi Dasar
Sebelum masuk ke strategi, kenali semua operasi dasar yang tersedia:
use Illuminate\Support\Facades\Cache;
// Simpan dengan TTL (time-to-live) — kedaluwarsa setelah 1 jam
Cache::put('articles.featured', $articles, now()->addHour());
Cache::put('articles.featured', $articles, 3600); // sama, dalam detik
// Simpan selamanya — tidak kedaluwarsa kecuali dihapus manual
Cache::forever('site.settings', $settings);
// Ambil — null kalau tidak ada
$articles = Cache::get('articles.featured');
// Ambil dengan nilai default kalau tidak ada
$articles = Cache::get('articles.featured', []);
$articles = Cache::get('articles.featured', fn() => Article::latest()->get());
// Cek keberadaan
if (Cache::has('articles.featured')) { ... }
// Hapus satu key
Cache::forget('articles.featured');
// Hapus semua cache — hati-hati di produksi
Cache::flush();
🧠 Strategi — Cara Berpikir tentang Cache
Ini bagian terpenting. Punya semua operasi di atas tidak cukup — kamu perlu tahu pola pikir yang tepat untuk tiap situasi.
Strategi 1: Cache-Aside (Lazy Loading) — “Simpan Saat Dibutuhkan”
Ini strategi paling umum dan paling aman untuk pemula. Prinsipnya: cek cache dulu, kalau tidak ada baru ambil dari DB dan simpan.
Laravel menyediakan remember() yang langsung menggabungkan ketiga langkah itu:
// Tanpa remember() — harus tulis sendiri tiga langkah
$articles = Cache::get('articles.published');
if (!$articles) {
$articles = Article::with('user')
->where('status', 'published')
->latest('published_at')
->get();
Cache::put('articles.published', $articles, now()->addMinutes(30));
}
// Dengan remember() — satu baris, logika sama persis
$articles = Cache::remember('articles.published', now()->addMinutes(30), function () {
return Article::with('user')
->where('status', 'published')
->latest('published_at')
->get();
});
📌 remember() adalah jantung dari caching di Laravel. Kalau key ada di cache, closure tidak dijalankan sama sekali — query tidak terjadi. Kalau tidak ada, closure dijalankan, hasilnya disimpan, dan dikembalikan.
Studi kasus lengkap di ArticleService:
📁 app/Services/ArticleService.php
<?php
namespace App\Services;
use App\Models\Article;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Collection;
class ArticleService
{
public function getPublished(): Collection
{
// Key yang deskriptif dan konsisten — mudah dilacak dan di-invalidate
return Cache::remember('articles.published', now()->addMinutes(30), function () {
return Article::with('user')
->where('status', 'published')
->latest('published_at')
->get();
});
}
public function findWithComments(int $id): Article
{
// Key per-record — tiap artikel punya cache sendiri
return Cache::remember("article.{$id}.with_comments", now()->addHour(), function () use ($id) {
return Article::with(['user', 'comments.user'])
->findOrFail($id);
});
}
public function getPopular(int $limit = 5): Collection
{
// Data yang jarang berubah bisa cache lebih lama
return Cache::remember("articles.popular.{$limit}", now()->addDay(), function () use ($limit) {
return Article::withCount('comments')
->where('status', 'published')
->orderByDesc('comments_count')
->limit($limit)
->get();
});
}
}
Strategi 2: Cache Invalidation — “Hapus Cache Saat Data Berubah”
Menyimpan cache mudah. Yang susah adalah memastikan cache tidak basi — menampilkan data lama padahal data aslinya sudah berubah.
Cara paling andal: hapus cache yang relevan setiap kali data berubah.
📁 app/Services/ArticleService.php (lanjutan)
public function create(array $data, User $author): Article
{
$article = Article::create([
'user_id' => $author->id,
'title' => $data['title'],
'body' => $data['body'],
'status' => $data['status'],
'published_at' => $data['status'] === 'published' ? now() : null,
]);
// Artikel baru dibuat → cache daftar artikel sudah tidak valid
$this->clearArticleListCache();
return $article;
}
public function update(Article $article, array $data): Article
{
$article->update($data);
// Data artikel berubah → cache artikel ini dan cache daftar tidak valid lagi
Cache::forget("article.{$article->id}.with_comments");
$this->clearArticleListCache();
return $article->fresh();
}
public function delete(Article $article): void
{
$article->delete();
Cache::forget("article.{$article->id}.with_comments");
$this->clearArticleListCache();
}
private function clearArticleListCache(): void
{
Cache::forget('articles.published');
Cache::forget('articles.popular.5');
Cache::forget('articles.popular.10');
}
📌 Ini disebut manual invalidation — kamu yang bertanggung jawab menghapus cache saat data berubah. Pendekatan ini eksplisit dan mudah dipahami, tapi butuh disiplin: setiap operasi tulis harus diikuti dengan membersihkan cache yang relevan.
Strategi 3: Cache Tags — “Hapus Sekelompok Sekaligus”
Masalah manual invalidation: kamu harus hafal semua key yang perlu dihapus. Kalau key-nya banyak dan tersebar, mudah kelewatan satu.
Cache tags memungkinkan kamu memberi label pada sekelompok cache, lalu menghapus semuanya dengan satu perintah.
// Simpan dengan tag
Cache::tags(['articles', 'published'])->remember(
'articles.published',
now()->addMinutes(30),
fn() => Article::published()->with('user')->get()
);
Cache::tags(['articles'])->remember(
"article.{$id}.detail",
now()->addHour(),
fn() => Article::with(['user', 'comments'])->findOrFail($id)
);
Cache::tags(['articles', 'popular'])->remember(
'articles.popular',
now()->addDay(),
fn() => Article::popular()->limit(5)->get()
);
// Kalau artikel dibuat/diupdate/dihapus — satu baris bersihkan semua
public function create(array $data, User $author): Article
{
$article = Article::create([...]);
Cache::tags(['articles'])->flush(); // hapus semua cache bertag 'articles'
return $article;
}
📌 Cache tags hanya tersedia untuk driver Redis dan Memcached — tidak bisa dipakai dengan file atau database driver. Kalau kamu pakai file driver di development tapi Redis di production, pastikan test cache invalidation-mu dilakukan di environment yang sama dengan production.
Strategi 4: rememberForever — Cache yang Dikontrol Manual
Untuk data yang hampir tidak pernah berubah — pengaturan situs, daftar kategori, statistik bulanan — cache selamanya dan hapus manual hanya saat data benar-benar berubah.
📁 app/Services/SettingService.php
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
class SettingService
{
public function getAll(): array
{
// Simpan selamanya — tidak ada TTL
return Cache::rememberForever('site.settings', function () {
return Setting::all()->pluck('value', 'key')->toArray();
});
}
public function update(string $key, string $value): void
{
Setting::updateOrCreate(['key' => $key], ['value' => $value]);
// Hapus cache — akan di-rebuild otomatis saat diakses berikutnya
Cache::forget('site.settings');
}
}
Strategi 5: Cache Lock — “Cegah Stampede”
Ada satu masalah yang sering diabaikan: cache stampede (disebut juga thundering herd).
Bayangkan cache artikel populer kedaluwarsa persis ketika 200 user mengaksesnya bersamaan. Sebelum cache sempat diisi ulang, ke-200 request itu masing-masing langsung query ke database — database mendapat 200 query identik dalam satu detik.
Cache lock mencegah ini: hanya satu proses yang boleh rebuild cache, sisanya menunggu.
public function getPublishedArticles(): Collection
{
$cacheKey = 'articles.published';
// Coba ambil dari cache dulu
if ($cached = Cache::get($cacheKey)) {
return $cached;
}
// Minta lock selama 10 detik — hanya satu proses yang bisa masuk
return Cache::lock('lock.' . $cacheKey, 10)->block(5, function () use ($cacheKey) {
// Double-check: mungkin proses lain sudah isi cache saat kita menunggu
if ($cached = Cache::get($cacheKey)) {
return $cached; // ← dapat dari cache, tidak perlu query lagi
}
// Baru sekarang query ke DB
$articles = Article::with('user')
->where('status', 'published')
->latest('published_at')
->get();
Cache::put($cacheKey, $articles, now()->addMinutes(30));
return $articles;
});
}
📌 block(5, ...) artinya: tunggu maksimal 5 detik untuk dapat lock. Kalau tidak dapat dalam 5 detik, lempar LockTimeoutException. Double-check setelah dapat lock penting — kalau proses lain sudah rebuild cache saat kita menunggu, tidak perlu query lagi.
🗝️ Penamaan Key yang Baik
Key yang buruk adalah sumber bug cache yang paling sering terjadi. Beberapa aturan yang membuat hidup lebih mudah:
// ✅ Pola hierarkis dengan titik sebagai pemisah
'articles.published'
'articles.popular.5'
"article.{$id}.detail"
"article.{$id}.with_comments"
"user.{$userId}.articles"
"user.{$userId}.comment_count"
// ✅ Sertakan semua parameter yang mempengaruhi hasil
"articles.published.page.{$page}"
"articles.tag.{$tagSlug}"
"search.articles.{$query}.page.{$page}"
// ❌ Terlalu generik — sulit di-invalidate dengan tepat
'data'
'results'
'articles' // halaman berapa? filter apa?
// ❌ Tanpa parameter pembeda — berbagi cache padahal hasilnya beda
'user_articles' // user mana?
'popular' // limit berapa?
📌 Bayangkan key cache seperti nama variabel yang harus bisa menjelaskan isinya tanpa perlu membaca kodenya. Sertakan semua variabel yang mempengaruhi hasil sebagai bagian dari key.
🧪 Cache di Testing
Untuk unit test, pakai driver array dan manfaatkan Cache::fake():
// Di test — fake Cache untuk assertion
public function test_article_list_is_cached(): void
{
Cache::shouldReceive('remember')
->once()
->with('articles.published', \Mockery::any(), \Mockery::any())
->andReturn(collect([]));
$this->getJson('/api/articles')->assertOk();
}
// Pakai Cache::fake() untuk tes invalidation
public function test_cache_cleared_after_article_created(): void
{
Cache::fake();
$this->postJson('/api/articles', [
'title' => 'Artikel Baru',
'body' => str_repeat('a', 100),
'status' => 'published',
]);
// Assert cache yang seharusnya dihapus memang terhapus
Cache::assertForgotten('articles.published');
}
🗺️ Gambaran Besar — Strategi per Tipe Data
Tipe Data Strategi TTL Notes
──────────────────────────────────────────────────────────────────────
Daftar artikel published remember() 30 menit Invalidate saat artikel CRUD
Detail artikel + komentar remember() 1 jam Invalidate saat artikel/komentar CRUD
Artikel populer remember() 1 hari Invalidate saat artikel CRUD
Pengaturan situs rememberForever Manual Invalidate saat setting diupdate
Hasil pencarian remember() 10 menit Key sertakan query string
Statistik bulanan rememberForever Manual Rebuild tiap awal bulan
Dan alur invalidation lengkapnya:
Article dibuat / diupdate / dihapus
→ Cache::tags(['articles'])->flush() ← kalau pakai tags
→ atau clearArticleListCache() ← kalau manual
Comment disetujui / dihapus
→ Cache::forget("article.{$id}.with_comments")
Setting diupdate
→ Cache::forget('site.settings')
🧭 Ringkasan Strategi
| Strategi | Method | Cocok untuk |
|---|---|---|
| Cache-Aside | remember() | Data yang sering dibaca, sesekali berubah |
| Forever | rememberForever() + forget() | Data yang hampir tidak pernah berubah |
| Tag-based | tags()->flush() | Banyak key terkait yang perlu dihapus bersama |
| Cache Lock | lock()->block() | Data traffic tinggi, cegah stampede |
💡 Ingat!
Ada dua kesalahan paling umum dalam caching yang perlu kamu hindari.
Pertama: terlalu banyak cache. Tidak semua data perlu di-cache. Data yang berubah sangat sering justru membuat cache menjadi beban karena terlalu sering di-invalidate. Cache paling efektif untuk data yang read-heavy tapi write-light.
Kedua: lupa invalidate. Cache yang tidak di-invalidate dengan benar menampilkan data basi — dan ini lebih berbahaya dari tidak ada cache sama sekali, karena kamu berpikir data sudah benar padahal tidak. Setiap kali kamu tambah operasi tulis, tanya: “cache mana yang perlu dihapus setelah ini?”
Kalau kamu menjaga dua hal itu, cache akan menjadi salah satu optimasi dengan dampak terbesar yang bisa kamu tambahkan ke aplikasi Laravel.
Di artikel-artikel sebelumnya, kita sudah beberapa kali menyebut “IoC Container” dan “Service Container” — biasanya dengan kalimat seperti “Laravel akan resolve ini otomatis” atau “Container yang menyediakan object-nya.”
Tapi kita belum pernah buka: bagaimana tepatnya Laravel tahu harus buat object apa, dengan dependensi apa, dalam urutan apa?
Itu yang akan kita bahas di artikel ini. Bukan hanya cara pakainya — tapi cara berpikirnya. Karena begitu kamu paham cara Container bekerja di dalam, semua hal yang terasa “ajaib” di Laravel akan jadi masuk akal.
🤔 Masalah yang Container Selesaikan
Kita mulai dari kenyataan yang tidak nyaman dulu.
Bayangin kamu punya ArticleController yang butuh ArticleService. ArticleService butuh ArticleRepository. ArticleRepository butuh koneksi database. Koneksi database butuh konfigurasi dari .env.
Kalau harus rakit manual:
// Tanpa container — kamu merakit semua sendiri
$config = new DatabaseConfig(env('DB_HOST'), env('DB_NAME'));
$connection = new DatabaseConnection($config);
$repository = new EloquentArticleRepository($connection);
$service = new ArticleService($repository);
$controller = new ArticleController($service);
$controller->index();
Ini baru empat lapis. Aplikasi nyata bisa sepuluh lapis, dengan cabang di tiap lapisnya. Kalau ada yang berubah di tengah rantai — misalnya ArticleRepository butuh parameter baru — kamu harus update semua tempat yang merakit rantai itu.
Service Container adalah solusinya. Kamu cukup daftarkan aturan perakitan sekali, dan setiap kali butuh object, Container yang merakit seluruh rantainya — otomatis.
🏭 Analogi Besar — Pabrik dengan Buku Resep
Bayangin sebuah pabrik makanan. Di pabrik ini ada gudang bahan baku, buku resep, dan mesin produksi.
Kalau kamu pesan “Nasi Goreng Spesial”:
- Mesin cek buku resep: “Nasi Goreng Spesial butuh nasi, telur, dan bumbu rahasia.”
- Mesin ambil semua bahan dari gudang.
- Kalau ada bahan yang juga punya resepnya sendiri (bumbu rahasia = bawang + cabai + terasi), mesin selesaikan itu dulu.
- Baru setelah semua bahan siap, produk jadi dibuat dan diserahkan.
Service Container di Laravel persis seperti itu:
- Gudang = IoC Container (menyimpan binding dan instance)
- Buku resep = binding yang kamu tulis di Service Provider
- Mesin produksi = proses resolve yang berjalan rekursif
- Pesanan = type-hint di constructor atau
app()->make(...)
🔍 Auto-resolve — Resolve Tanpa Instruksi
Kemampuan paling mendasar Container adalah autowiring — kemampuan membuat object kelas konkret tanpa kamu mendaftarkan apapun.
Cara kerjanya: Container membaca constructor sebuah kelas menggunakan PHP Reflection API, melihat type-hint tiap parameternya, lalu membuat dependensi itu satu per satu secara rekursif.
// Tidak perlu daftarkan apapun — Container resolve sendiri
class ArticleRepository
{
// Tidak ada constructor — tidak ada dependensi
}
class ArticleService
{
public function __construct(
private ArticleRepository $repository // ← Container baca ini
) {}
}
class ArticleController extends Controller
{
public function __construct(
private ArticleService $service // ← Container baca ini
) {}
}
Yang terjadi di balik layar ketika request masuk ke ArticleController:
Container dapat permintaan: "buat ArticleController"
↓
Baca constructor ArticleController → butuh ArticleService
↓
Baca constructor ArticleService → butuh ArticleRepository
↓
Baca constructor ArticleRepository → tidak butuh apa-apa
↓
Buat ArticleRepository ✓
↓
Buat ArticleService(repository: ArticleRepository) ✓
↓
Buat ArticleController(service: ArticleService) ✓
📌 Proses ini rekursif — Container menyelesaikan dependensi dari dalam ke luar, seperti mengupas bawang dari tengah. Dan semua ini terjadi hanya karena kamu nulis type-hint yang benar. Tidak ada konfigurasi tambahan.
PHP Reflection API — Alat yang Dipakai
Container menggunakan ReflectionClass bawaan PHP untuk “membaca” struktur kelas tanpa menjalankannya:
// Ini yang dilakukan Container di balik layar (disederhanakan)
$reflection = new ReflectionClass(ArticleService::class);
$constructor = $reflection->getConstructor();
$parameters = $constructor->getParameters();
foreach ($parameters as $param) {
$type = $param->getType()->getName(); // → "App\Repositories\ArticleRepository"
$dependencies[] = $this->make($type); // → resolve rekursif
}
return $reflection->newInstanceArgs($dependencies);
📌 Itulah kenapa type-hint wajib ditulis dengan nama kelas/interface yang benar. Kalau parameternya $repo tanpa type-hint, Container tidak tahu harus inject apa — dan akan lempar error.
📋 Binding — Resolve dengan Instruksi
Auto-resolve tidak bisa bekerja untuk interface — karena interface tidak bisa di-instansiasi. Container butuh instruksi eksplisit: “kalau diminta interface ini, buat implementasi itu.”
Ini yang disebut binding.
bind — Baru Tiap Kali
// Setiap kali ada yang minta ArticleRepositoryInterface,
// Container buat EloquentArticleRepository yang baru
$this->app->bind(
ArticleRepositoryInterface::class,
EloquentArticleRepository::class
);
singleton — Buat Sekali, Pakai Terus
// Container buat ArticleService sekali saja.
// Semua yang minta ArticleService dapat object yang sama persis.
$this->app->singleton(ArticleService::class);
// Untuk interface → implementasi, sintaksnya sama:
$this->app->singleton(
CacheRepositoryInterface::class,
RedisCacheRepository::class
);
bind dengan Closure — Kontrol Penuh
Kadang kamu butuh kontrol lebih atas bagaimana object dibuat — misalnya perlu inject konfigurasi atau kondisi tertentu:
$this->app->bind(ArticleRepositoryInterface::class, function ($app) {
// $app adalah Container itu sendiri — bisa resolve dependensi lain
return new EloquentArticleRepository(
$app->make(DatabaseManager::class),
config('blog.per_page') // inject nilai dari konfigurasi
);
});
instance — Daftarkan Object yang Sudah Jadi
// Kalau object sudah kamu buat sebelumnya dan mau didaftarkan
$config = new BlogConfig(['per_page' => 10, 'allow_guest_comments' => false]);
$this->app->instance('blog.config', $config);
// Nanti bisa diambil dengan:
$config = app('blog.config');
📌 Perbedaan bind dan singleton yang paling mudah diingat: kalau kelas menyimpan state yang berubah antar penggunaan, pakai bind — supaya tiap caller dapat object yang bersih. Kalau tidak ada state yang perlu di-reset, pakai singleton — lebih hemat memori karena object tidak dibuat ulang.
🎯 Contextual Binding — Inject Berbeda untuk Kelas Berbeda
Ini fitur yang jarang dibahas tapi sangat berguna: Container bisa inject implementasi berbeda ke kelas yang berbeda, meskipun keduanya minta interface yang sama.
Studi kasus: ArticleController butuh FileStorageInterface yang simpan ke disk lokal. ImageUploadController butuh FileStorageInterface yang sama, tapi harus simpan ke S3.
// Di AppServiceProvider::register()
$this->app
->when(ArticleController::class) // ← khusus untuk kelas ini
->needs(FileStorageInterface::class) // ← ketika butuh interface ini
->give(LocalFileStorage::class); // ← berikan implementasi ini
$this->app
->when(ImageUploadController::class) // ← kelas berbeda
->needs(FileStorageInterface::class) // ← minta interface yang sama
->give(S3FileStorage::class); // ← dapat implementasi berbeda
Kedua controller tetap type-hint dengan interface yang sama — tidak ada perubahan di sana:
class ArticleController extends Controller
{
public function __construct(
private FileStorageInterface $storage // ← dapat LocalFileStorage
) {}
}
class ImageUploadController extends Controller
{
public function __construct(
private FileStorageInterface $storage // ← dapat S3FileStorage
) {}
}
📌 Contextual binding sangat berguna ketika satu interface punya banyak implementasi dan kamu butuh kontrol tepat siapa dapat apa — tanpa harus bikin interface baru hanya untuk membedakan keduanya.
Contextual Binding untuk Nilai Primitif
Contextual binding juga bekerja untuk nilai non-object seperti string atau integer:
// ArticleService dapat 10 item per halaman
$this->app
->when(ArticleService::class)
->needs('$perPage') // ← cocok dengan nama parameter di constructor
->give(10);
// AdminArticleService dapat 50 item per halaman
$this->app
->when(AdminArticleService::class)
->needs('$perPage')
->give(50);
class ArticleService
{
public function __construct(
private ArticleRepositoryInterface $repository,
private int $perPage // ← Container inject nilai 10 di sini
) {}
}
🏷️ Tagging — Resolve Banyak Sekaligus
Kalau kamu punya beberapa implementasi berbeda yang semua perlu dijalankan bersamaan, kamu bisa tag mereka dan resolve semuanya sekaligus.
Studi kasus: notifikasi artikel published dikirim ke email, Slack, dan database.
// Di Service Provider — daftarkan dan beri tag yang sama
$this->app->tag([
EmailNotificationChannel::class,
SlackNotificationChannel::class,
DatabaseNotificationChannel::class,
], 'notification.channels');
// Inject semua yang punya tag itu sekaligus lewat Closure
$this->app->bind(ArticlePublishedNotifier::class, function ($app) {
return new ArticlePublishedNotifier(
$app->tagged('notification.channels') // ← resolve semua sekaligus
);
});
class ArticlePublishedNotifier
{
public function __construct(
private iterable $channels // ← berisi semua tiga channel
) {}
public function notify(Article $article): void
{
foreach ($this->channels as $channel) {
$channel->send($article); // kirim ke semua
}
}
}
📌 Tagging adalah pasangan alami dari prinsip Open/Closed di SOLID. Mau tambah WhatsAppNotificationChannel besok? Cukup daftarkan dan tag — ArticlePublishedNotifier tidak perlu disentuh sama sekali.
🔁 Resolving Callback — Kode yang Jalan Setelah Object Dibuat
Kadang kamu butuh melakukan sesuatu setiap kali Container selesai membuat object tertentu:
// Setiap kali ArticleRepository berhasil dibuat,
// jalankan kode ini sebelum object diserahkan ke caller
$this->app->resolving(ArticleRepository::class, function ($repo, $app) {
$repo->setPerPage(config('blog.per_page'));
$repo->setOrderBy('published_at', 'desc');
});
Atau untuk semua object apapun:
// Jalankan untuk SEMUA object yang di-resolve dari container
$this->app->resolving(function ($object, $app) {
if (method_exists($object, 'setLocale')) {
$object->setLocale(app()->getLocale());
}
});
🛠️ Cara Mengambil dari Container
Ada beberapa cara resolve object secara manual — masing-masing punya tempat yang tepat:
// 1. Helper app() — paling ringkas
$service = app(ArticleService::class);
$config = app('blog.config');
// 2. Facade App::make() — identik dengan app()
use Illuminate\Support\Facades\App;
$service = App::make(ArticleService::class);
// 3. Lewat $this->app di Service Provider
$service = $this->app->make(ArticleService::class);
// 4. Type-hint — cara yang paling dianjurkan
// Container inject otomatis, tidak perlu panggil app() sama sekali
public function __construct(private ArticleService $service) {}
📌 Di kode aplikasi sehari-hari, hindari app() dan App::make() sebisa mungkin — itu membuat dependensi tersembunyi dan sulit dites. Type-hint di constructor tetap cara terbaik. app() lebih cocok dipakai di Service Provider, global helper function, atau tempat di mana constructor injection tidak tersedia.
🗺️ Gambaran Besar — Semua Cara Binding
IoC Container
│
├── Auto-resolve
│ └── Kelas konkret + type-hint benar → rekursif otomatis, tanpa daftar
│
├── bind()
│ ├── Interface → Kelas konkret → object baru tiap kali
│ └── Interface → Closure → kontrol penuh cara pembuatan
│
├── singleton()
│ └── Sama seperti bind(), tapi object dibuat SEKALI lalu di-cache
│
├── instance()
│ └── Daftarkan object yang sudah jadi
│
├── Contextual Binding
│ └── Interface yang sama → implementasi berbeda per kelas penerima
│
├── tag() + tagged()
│ └── Kumpulkan banyak kelas dalam satu label → resolve sekaligus
│
└── resolving()
└── Callback yang jalan setiap kali object berhasil dibuat
Dan posisi Container di antara komponen lain yang sudah kita pelajari:
Service Provider
→ register() mendaftarkan binding ke Container
→ boot() menjalankan setup setelah semua binding siap
↓
IoC Container menyimpan semua aturan dan instance
↓
DI (constructor/method) → Container resolve dan inject otomatis
Facade → Container ambil object lewat getFacadeAccessor()
app() → Container resolve secara manual
🧭 Kapan Pakai Yang Mana?
Auto-resolve untuk kelas konkret yang semua dependensinya juga kelas konkret. Tidak perlu daftarkan apa-apa.
bind() ketika type-hint pakai interface, atau ketika cara membuat object butuh logika kustom lewat Closure.
singleton() untuk service stateless yang berat dibuat ulang — mayoritas service di aplikasi blog cocok di sini.
Contextual Binding ketika satu interface punya implementasi berbeda untuk konteks berbeda, tanpa mau bikin interface baru hanya untuk membedakan.
tag() ketika ada banyak implementasi yang harus dijalankan semua — notifikasi multi-channel, validator pipeline, atau sistem plugin.
resolving() ketika butuh setup tambahan setelah object dibuat, tanpa ubah konstruktornya.
📊 Perbandingan Semua Jenis Binding
| Jenis | Object Dibuat | Butuh Daftar? | Cocok untuk |
|---|---|---|---|
| Auto-resolve | Tiap kali | Tidak | Kelas konkret tanpa interface |
bind | Tiap kali | Ya | Interface → implementasi |
singleton | Sekali | Ya | Service stateless |
instance | Sudah ada | Ya | Object yang sudah dibuat manual |
| Contextual | Tiap kali | Ya | Interface beda implementasi per kelas |
tag | Tiap kali | Ya | Resolve banyak implementasi sekaligus |
💡 Ingat!
Waktu pertama kali belajar ini, wajar kalau terasa “ini terlalu dalam — tahu bind() dan singleton() saja sudah cukup.”
Untuk kebanyakan proyek awal, memang iya. Tapi satu hal yang paling penting untuk dibawa pulang dari artikel ini: Container bukan sihir. Dia membaca type-hint lewat PHP Reflection, mencocokkan dengan binding yang didaftarkan, membuat object dari dalam ke luar secara rekursif, lalu menyerahkannya.
Itu saja. Setiap kali kamu type-hint ArticleService $service di constructor dan object-nya muncul entah dari mana — proses itulah yang terjadi.
Kalau kamu pernah nulis Cache::get('key') atau Log::info('pesan') di Laravel, kamu sudah pakai Facade — mungkin tanpa sadar.
Dan kalau kamu pernah buka AppServiceProvider dan nulis $this->app->bind(...) di dalamnya, kamu sudah pakai Service Provider — mungkin juga tanpa terlalu paham apa yang sebenarnya terjadi.
Dua topik ini sering diajarkan bersamaan, dan sering bikin bingung karena terasa tumpang tindih. Padahal tugasnya sangat berbeda: Service Provider mendaftarkan sesuatu ke container, Facade mengakses sesuatu dari container. Yang satu memasukkan, yang lain mengambil.
Kita mulai dari masing-masing, baru lihat bagaimana keduanya bekerja bersama.
📦 Service Provider — “Petugas Gudang yang Mengisi Rak”
Analoginya
Bayangin sebuah toko kelontong. Sebelum toko buka, ada petugas gudang yang tugasnya mengisi rak-rak — menaruh beras di rak A, minyak di rak B, gula di rak C. Dia juga yang memutuskan: kalau stok minyak merek X habis, ganti dengan merek Y yang rasanya sama.
Petugas ini bekerja sebelum toko buka. Saat pelanggan datang, rak sudah terisi. Pelanggan tidak perlu tahu dari mana barang-barang itu datang — mereka tinggal ambil.
Service Provider adalah petugas gudang itu. Dia bekerja saat Laravel bootstrap — sebelum request pertama masuk — untuk mendaftarkan semua yang dibutuhkan aplikasi ke IoC Container.
Dua Method yang Selalu Ada
Setiap Service Provider punya dua method, dan urutannya tidak boleh ditukar:
register() → isi rak (daftarkan binding ke container)
boot() → buka toko (jalankan sesuatu setelah semua rak terisi)
Kenapa harus dipisah? Karena boot() boleh mengakses binding dari provider lain — tapi register() tidak boleh. Kalau kamu akses service di register(), service itu mungkin belum didaftarkan oleh provider lain.
Kodenya — Service Provider Dasar
Studi kasus: kita buat Service Provider untuk mendaftarkan ArticleService dan semua binding yang dibutuhkan blog.
📁 app/Providers/BlogServiceProvider.php
<?php
namespace App\Providers;
use App\Contracts\ArticleRepositoryInterface;
use App\Contracts\CommentRepositoryInterface;
use App\Repositories\EloquentArticleRepository;
use App\Repositories\EloquentCommentRepository;
use App\Models\Article;
use App\Observers\ArticleObserver;
use Illuminate\Support\ServiceProvider;
class BlogServiceProvider extends ServiceProvider
{
public function register(): void
{
// Daftarkan interface → implementasi konkret
// "Kalau ada yang minta ArticleRepositoryInterface, beri EloquentArticleRepository"
$this->app->bind(
ArticleRepositoryInterface::class,
EloquentArticleRepository::class
);
$this->app->bind(
CommentRepositoryInterface::class,
EloquentCommentRepository::class
);
// Daftarkan sebagai singleton — object dibuat sekali, dipakai ulang
// Cocok untuk service yang tidak punya state yang perlu di-reset
$this->app->singleton('blog.stats', function ($app) {
return new \App\Services\BlogStatisticsService(
$app->make(ArticleRepositoryInterface::class)
);
});
}
public function boot(): void
{
// Di sini aman untuk akses service dari provider lain
// karena semua register() sudah selesai dijalankan
// Daftarkan observer — pantau event Model
Article::observe(ArticleObserver::class);
// Daftarkan view composer — inject data ke semua view yang butuh
view()->composer('layouts.sidebar', function ($view) {
$view->with('recentArticles',
$this->app->make(ArticleRepositoryInterface::class)->getRecent(5)
);
});
}
}
📌 BlogServiceProvider tidak pernah dipanggil langsung di kode kamu. Laravel yang memanggilnya otomatis saat bootstrap, berdasarkan daftar di bootstrap/app.php (Laravel 11) atau config/app.php (Laravel sebelumnya).
Mendaftarkan Provider ke Aplikasi
📁 bootstrap/app.php (Laravel 11)
$app = Application::create(basePath: dirname(__DIR__))
->withProviders([
// Provider buatan kita didaftarkan di sini
\App\Providers\BlogServiceProvider::class,
])
->withRouting(...)
->withMiddleware(...);
Tiga Jenis Binding yang Paling Sering Dipakai
// 1. bind → buat object baru setiap kali diminta dari container
$this->app->bind(ArticleRepositoryInterface::class, EloquentArticleRepository::class);
// 2. singleton → buat sekali, pakai object yang sama seterusnya
$this->app->singleton(CacheService::class, CacheService::class);
// 3. instance → daftarkan object yang sudah jadi (sudah di-new sebelumnya)
$this->app->instance('config.blog', new BlogConfig([
'per_page' => 10,
'allow_comments' => true,
]));
📌 Pilih bind kalau tiap penggunaan butuh state bersih. Pilih singleton untuk service stateless yang berat dibuat ulang (koneksi, konfigurasi, cache driver). Pilih instance kalau object-nya sudah kamu buat sendiri sebelum di-bind.
🪟 Facade — “Label di Rak yang Bisa Langsung Diambil”
Analoginya
Kembali ke toko kelontong. Pelanggan datang dan langsung ambil gula dari rak B. Mereka tidak perlu tahu gula itu dari pabrik mana, truk apa yang ngantarnya, atau petugas gudang mana yang naruhnya.
Mereka cuma lihat label “GULA” di rak — dan langsung ambil.
Facade adalah label itu. Dia memberikan cara singkat dan statis untuk mengakses service dari IoC Container, tanpa harus inject lewat constructor atau resolve manual.
Tanpa Facade vs Dengan Facade
Ini perbedaan yang paling jelas:
// Tanpa Facade — harus inject lewat constructor atau resolve manual
class ArticleController extends Controller
{
public function __construct(
private \Illuminate\Cache\CacheManager $cache
) {}
public function show(Article $article): JsonResponse
{
$cached = $this->cache->remember("article.{$article->id}", 3600, fn() => $article);
return response()->json($cached);
}
}
// Dengan Facade — langsung panggil dari mana saja
use Illuminate\Support\Facades\Cache;
class ArticleController extends Controller
{
public function show(Article $article): JsonResponse
{
$cached = Cache::remember("article.{$article->id}", 3600, fn() => $article);
return response()->json($cached);
}
}
Keduanya melakukan hal yang persis sama. Facade hanya shortcut — di balik layar, Cache::remember() tetap memanggil object CacheManager yang ada di IoC Container.
Cara Kerja Facade di Balik Layar
Ini yang bikin Facade terasa “ajaib” padahal mekanismenya sederhana.
Setiap Facade punya satu method yang wajib ada: getFacadeAccessor(). Method ini cukup mengembalikan key yang dipakai untuk mengambil service dari IoC Container.
📁 vendor/laravel/framework/src/Illuminate/Support/Facades/Cache.php
Ini file asli milik Laravel — bukan buatan kita. Isinya sangat pendek.
<?php
namespace Illuminate\Support\Facades;
class Cache extends Facade
{
// Kembalikan key yang dipakai untuk ambil service dari container
protected static function getFacadeAccessor(): string
{
return 'cache'; // ← ini key-nya
}
}
Lalu di base class Facade, ada sihir PHP bernama __callStatic() yang bekerja:
// Di Illuminate\Support\Facades\Facade (disederhanakan)
public static function __callStatic(string $method, array $args): mixed
{
// 1. Ambil service dari container berdasarkan key
$instance = static::getFacadeRoot(); // → app('cache')
// 2. Panggil method yang diminta di object itu
return $instance->$method(...$args);
}
Jadi Cache::remember(...) secara harfiah berarti: “ambil object ‘cache’ dari container, lalu panggil method remember() di object itu.”
📌 __callStatic() adalah magic method PHP yang dipanggil otomatis ketika kamu memanggil method statis yang tidak ada. Facade memanfaatkan ini untuk “meneruskan” semua panggilan ke object aslinya di container.
Membuat Facade Sendiri
Kalau kamu punya service kustom dan mau aksesnya semudah Cache::get() — kamu bisa buat Facade sendiri.
Studi kasus: kita buat Facade untuk BlogStatisticsService.
Langkah 1 — Service-nya sudah ada (sudah didaftarkan di BlogServiceProvider tadi dengan key 'blog.stats'):
// Sudah ada di BlogServiceProvider::register():
$this->app->singleton('blog.stats', function ($app) {
return new \App\Services\BlogStatisticsService(...);
});
Langkah 2 — Buat kelas Facade-nya:
📁 app/Facades/BlogStats.php
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class BlogStats extends Facade
{
protected static function getFacadeAccessor(): string
{
// Harus cocok dengan key yang didaftarkan di Service Provider
return 'blog.stats';
}
}
Langkah 3 — Langsung pakai di mana saja:
use App\Facades\BlogStats;
// Di controller, blade, atau mana saja — tanpa inject apapun
$totalArticles = BlogStats::getTotalArticles();
$totalComments = BlogStats::getTotalComments();
$popularArticle = BlogStats::getMostRead();
📌 Tiga file yang terlibat: Service (logika aslinya), Service Provider (yang mendaftarkan ke container dengan key tertentu), Facade (yang menunjuk ke key itu). Kalau salah satu tidak ada, Facade tidak akan jalan.
🔗 Keduanya Bekerja Bersama
Ini yang paling penting untuk dipahami: Service Provider dan Facade tidak bisa dipisahkan.
Facade hanya bisa bekerja kalau service yang ditunjuknya sudah didaftarkan ke container oleh Service Provider. Urutannya selalu:
Service Provider register() → IoC Container punya 'blog.stats'
↓
Facade getFacadeAccessor() mengembalikan 'blog.stats'
↓
__callStatic() ambil object dari container → panggil method
Kalau kamu pakai BlogStats::getTotalArticles() tapi lupa daftarkan 'blog.stats' di Service Provider, Laravel akan lempar error: “Target [blog.stats] is not instantiable.”
⚖️ Facade vs Dependency Injection — Mana yang Lebih Baik?
Ini pertanyaan yang sering muncul, dan jawabannya: tergantung konteks.
// Pakai Facade — cocok untuk:
// - Utility yang dipakai sesekali
// - Kode di luar class (helper, config, migration)
// - Prototipe cepat
Cache::put("article.{$id}", $article, 3600);
Log::info("Article {$id} diakses");
// Pakai DI — cocok untuk:
// - Dependensi utama sebuah class
// - Kode yang perlu di-test (Facade bisa di-mock, tapi DI lebih eksplisit)
// - Ketika ingin jelas "class ini bergantung pada apa"
class ArticleService
{
public function __construct(
private ArticleRepositoryInterface $repository, // ← jelas dan eksplisit
private CacheService $cache
) {}
}
📌 Di Laravel sendiri, keduanya dipakai berdampingan. Tidak ada aturan “harus pilih satu”. Tapi untuk service utama yang menjadi tulang punggung fitur — DI lebih disarankan karena lebih eksplisit dan mudah dites.
Facade Bisa Di-Mock di Test
Satu keunggulan Facade yang sering tidak diketahui pemula: kamu bisa fake Facade di unit test tanpa inject apapun.
// Di test — fake Cache supaya tidak menyentuh Redis/Memcached sungguhan
Cache::fake(); // atau Cache::shouldReceive('remember')->andReturn($fakeArticle);
// Di test — fake Mail supaya tidak kirim email sungguhan
Mail::fake();
// Setelah aksi yang ditest:
Mail::assertSent(ArticlePublishedMail::class);
🗺️ Gambaran Besar — Posisi Tiap File
app/
├── Providers/
│ ├── AppServiceProvider.php ← binding umum aplikasi
│ └── BlogServiceProvider.php ← binding khusus fitur blog
│
├── Facades/
│ └── BlogStats.php ← Facade kustom → menunjuk ke 'blog.stats'
│
├── Services/
│ └── BlogStatisticsService.php ← service asli yang bekerja
│
└── Contracts/
├── ArticleRepositoryInterface.php
└── CommentRepositoryInterface.php
Alur hubungannya:
BlogServiceProvider::register()
→ app()->singleton('blog.stats', BlogStatisticsService::class)
↓
IoC Container menyimpan 'blog.stats'
↓
BlogStats::getFacadeAccessor() → 'blog.stats'
↓
BlogStats::getTotalArticles() → __callStatic() → app('blog.stats')->getTotalArticles()
🧭 Kapan Pakai Yang Mana?
Buat Service Provider ketika: Kamu punya service, repository, atau konfigurasi yang perlu didaftarkan ke container — terutama kalau melibatkan interface yang perlu di-bind ke implementasi konkret. Satu fitur besar layak punya satu Service Provider sendiri.
Buat Facade ketika: Kamu punya service yang sering dipakai di banyak tempat dan ingin cara aksesnya singkat — tanpa harus inject di setiap constructor. Terutama untuk utility (cache, log, storage) yang bukan dependensi utama sebuah class.
Tetap pakai DI ketika: Service itu adalah dependensi inti sebuah class, dan kamu ingin kejelasan eksplisit tentang “class ini bergantung pada apa” — terutama untuk kode yang akan di-test.
📊 Perbandingan Singkat
| Service Provider | Facade | |
|---|---|---|
| Tugasnya | Daftarkan service ke container | Akses service dari container |
| Dijalankan kapan | Saat bootstrap, sebelum request masuk | Saat dipanggil |
| Ditulis di mana | app/Providers/ | app/Facades/ |
| Wajib ada method | register(), boot() | getFacadeAccessor() |
| Tanpa ini | Container kosong, DI dan Facade tidak jalan | Harus inject manual lewat DI |
💡 Ingat!
Waktu pertama kali lihat Cache::get() dan Log::info(), wajar kalau rasanya “ini static method biasa” — padahal di baliknya ada object penuh dari container yang bekerja.
Dan waktu pertama kali buka AppServiceProvider dan nulis bind(), wajar kalau rasanya “ini ngisi apa ke mana?” — padahal yang kamu isi itu adalah fondasi yang memungkinkan semua DI dan Facade bekerja.
Setelah kamu paham keduanya bekerja bersama — Service Provider mengisi container, Facade mengambil dari container — banyak hal di Laravel yang tadinya terasa “ajaib” akan mulai masuk akal.
Kalau kamu pernah debugging Laravel dan bingung “ini kode dijalankan dari mana? Siapa yang manggil ini duluan?” — itu pertanda kamu belum punya gambaran besar tentang lifecycle-nya.
Artikel ini akan tunjukkan perjalanan lengkap sebuah HTTP request: dari detik pertama browser ngirim request, sampai detik terakhir Laravel ngirim response balik. Bukan hanya teorinya — kita akan lihat file-file nyata yang terlibat dan apa yang mereka lakukan.
Analoginya satu dulu, baru kita ikutin perjalanannya tahap demi tahap.
🏨 Analogi Besar — Hotel
Bayangin sebuah hotel bintang lima.
Tamu datang → disambut pintu utama → dicek di resepsionis → diantarkan lewat koridor tertentu → sampai di kamar yang sesuai → pelayanan dilakukan → tamu puas dan pulang.
Di balik layar, ada manajer hotel yang sudah menyiapkan semua fasilitas sebelum hotel buka: lampu dinyalakan, staf ditempatkan, SOP dijalankan.
Request di Laravel persis seperti itu:
Browser (tamu)
↓
public/index.php (pintu utama)
↓
bootstrap/app.php (manajer hotel)
↓
HTTP Kernel (resepsionis)
↓
Middleware (pemeriksaan di koridor)
↓
Router (penunjuk arah ke kamar)
↓
Controller → Service → Model (kamar + pelayanan)
↓
Response (tamu pulang)
Sekarang kita buka satu per satu.
1️⃣ public/index.php — Pintu Utama
Semua request HTTP yang masuk ke Laravel — tanpa terkecuali — melewati satu file ini. Tidak ada jalan lain.
📁 public/index.php
<?php
// Memuat autoloader Composer — supaya semua kelas PHP bisa ditemukan
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Cek apakah aplikasi sedang dalam maintenance mode
// Kalau iya, tampilkan halaman maintenance dan berhenti di sini
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Autoloader Composer — mendaftarkan semua kelas dari vendor/ dan app/
require __DIR__.'/../vendor/autoload.php';
// Bootstrap aplikasi — ini yang membangun "hotel"-nya
$app = require_once __DIR__.'/../bootstrap/app.php';
// Tangkap request HTTP yang datang dari browser
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Request::capture() // ← request dari browser ditangkap di sini
);
$response->send(); // ← response dikirim balik ke browser
$kernel->terminate($request, $response); // ← cleanup setelah response dikirim
📌 File ini pendek dan tidak pernah kamu sentuh — tapi dia adalah titik masuk tunggal untuk semua request. Web server (Nginx/Apache) dikonfigurasi untuk selalu mengarahkan semua request ke file ini.
2️⃣ bootstrap/app.php — Manajer Hotel
Setelah pintu dibuka, giliran manajer yang bekerja: menyiapkan semua fondasi aplikasi sebelum request diproses.
📁 bootstrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
$app = Application::create(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php', // ← daftarkan file route web
api: __DIR__.'/../routes/api.php', // ← daftarkan file route API
commands: __DIR__.'/../routes/console.php',
)
->withMiddleware(function (Middleware $middleware) {
// ← konfigurasi middleware global di sini
})
->withExceptions(function (Exceptions $exceptions) {
// ← konfigurasi bagaimana exception ditangani
});
return $app;
📌 Di sinilah IoC Container (yang kita bahas di artikel DI) pertama kali hidup. Semua binding, service provider, dan konfigurasi dasar diinisialisasi di tahap ini. Kalau hotel adalah aplikasi, bootstrap/app.php adalah momen semua lampu dinyalakan dan semua staf disiapkan.
3️⃣ Service Provider — Staf yang Disiapkan Sebelum Buka
Sebelum request pertama masuk, Laravel menjalankan semua Service Provider yang terdaftar. Ini adalah tahap di mana semua “fasilitas hotel” disiapkan.
Setiap Service Provider punya dua method:
register() → daftarkan binding ke IoC Container
boot() → jalankan sesuatu setelah semua provider selesai register
📁 app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Contracts\CommentRepositoryInterface;
use App\Repositories\EloquentCommentRepository;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Tahap ini: daftarkan ke container
// "Kalau ada yang minta CommentRepositoryInterface, beri EloquentCommentRepository"
$this->app->bind(
CommentRepositoryInterface::class,
EloquentCommentRepository::class
);
}
public function boot(): void
{
// Tahap ini: semua provider sudah register, aman untuk akses service lain
// Contoh: daftarkan observer, gate, macro, view composer
\App\Models\Article::observe(\App\Observers\ArticleObserver::class);
}
}
📌 Urutan penting: semua register() dari semua provider dijalankan dulu, baru semua boot() dijalankan. Kenapa? Karena boot() boleh menggunakan binding yang didaftarkan provider lain — dan kalau urutannya terbalik, binding itu belum tentu ada.
4️⃣ HTTP Kernel — Resepsionis
Setelah aplikasi siap, request diserahkan ke HTTP Kernel. Ini adalah pusat komando yang menentukan apa saja yang harus dilalui sebuah request sebelum sampai ke controller.
📁 app/Http/Kernel.php (sebelum Laravel 11)
protected $middleware = [
// Middleware yang dijalankan untuk SEMUA request, tanpa terkecuali
\Illuminate\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Http\Middleware\ValidatePostSize::class,
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
];
protected $middlewareGroups = [
// Middleware untuk request dari browser (cookie, session, CSRF)
'web' => [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
// Middleware untuk API (stateless, pakai token)
'api' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
📌 Ada tiga lapis middleware di Kernel: global (semua request), group (web atau api), dan route-specific (hanya route tertentu). Request melewati ketiganya secara berurutan — seperti tamu yang harus lewat metal detector, lalu cek reservasi, lalu kunci kamar.
5️⃣ Middleware — Koridor Pemeriksaan
Middleware adalah kode yang berjalan sebelum dan/atau sesudah request sampai ke controller. Setiap middleware punya satu tanggung jawab spesifik.
Struktur middleware selalu sama:
public function handle(Request $request, Closure $next): Response
{
// Kode di sini → dijalankan SEBELUM request sampai ke controller
$response = $next($request); // ← request diteruskan ke middleware berikutnya
// Kode di sini → dijalankan SESUDAH controller selesai
return $response;
}
Contoh nyata — middleware untuk memastikan user sudah login sebelum bisa buat artikel:
📁 app/Http/Middleware/EnsureUserIsAuthor.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAuthor
{
public function handle(Request $request, Closure $next): Response
{
// Cek sebelum request masuk ke controller
if (!$request->user() || $request->user()->role !== 'author') {
return response()->json(['message' => 'Unauthorized.'], 403);
// ← kalau tidak lolos, request BERHENTI di sini, tidak sampai controller
}
// Lolos pemeriksaan → teruskan ke middleware berikutnya atau controller
return $next($request);
}
}
📁 routes/api.php
Middleware dipasang ke route yang membutuhkannya.
Route::middleware(['auth:sanctum', EnsureUserIsAuthor::class])
->group(function () {
Route::post('/articles', [ArticleController::class, 'store']);
Route::put('/articles/{article}', [ArticleController::class, 'update']);
Route::delete('/articles/{article}', [ArticleController::class, 'destroy']);
});
📌 Bayangkan middleware seperti koridor dengan beberapa pos pemeriksaan. Request harus lolos semua pos secara berurutan. Kalau gagal di satu pos, dia dikembalikan langsung — tidak perlu repot sampai ke controller.
Urutan Middleware yang Sebenarnya
Ini yang sering tidak disadari: middleware berjalan seperti tumpukan (stack), bukan antrian lurus.
Middleware A masuk →
Middleware B masuk →
Middleware C masuk →
Controller
← Middleware C keluar
← Middleware B keluar
← Middleware A keluar
Yang pertama masuk adalah yang terakhir keluar. Jadi kalau kamu butuh middleware yang berjalan paling akhir saat response — daftarkan dia paling awal.
6️⃣ Router — Penunjuk Arah
Setelah lolos semua middleware global, request diserahkan ke Router. Router membaca URL dan HTTP method dari request, lalu mencocokkannya dengan daftar route yang sudah didaftarkan.
📁 routes/api.php
<?php
use App\Http\Controllers\ArticleController;
use App\Http\Controllers\CommentController;
// GET /api/articles → ArticleController@index
Route::get('/articles', [ArticleController::class, 'index']);
// GET /api/articles/1 → ArticleController@show, {article} di-resolve otomatis
Route::get('/articles/{article}', [ArticleController::class, 'show']);
// POST /api/articles → butuh auth dulu
Route::middleware('auth:sanctum')->group(function () {
Route::post('/articles', [ArticleController::class, 'store']);
Route::post('/articles/{article}/comments', [CommentController::class, 'store']);
});
Route Model Binding — Fitur Yang Sering Tidak Disadari
Perhatikan {article} di route. Kalau parameter itu di-type-hint dengan Model di controller, Laravel otomatis mengambil data dari database — kamu tidak perlu Article::findOrFail($id) lagi.
📁 app/Http/Controllers/ArticleController.php
// Laravel otomatis query Article::findOrFail($article) sebelum method ini dipanggil
// Kalau tidak ketemu → otomatis 404
public function show(Article $article): JsonResponse
{
return response()->json($article->load('user', 'comments'));
}
📌 Ini namanya Route Model Binding — salah satu fitur Laravel yang paling menghemat kode. Cukup type-hint dengan Model yang sesuai nama parameter route, sisanya diurus otomatis.
7️⃣ Controller → Service → Model — Kamar dan Pelayanan
Request akhirnya sampai ke controller. Di sinilah logika aplikasi dijalankan — dan kalau kamu mengikuti SRP dari artikel SOLID, controller hanya bertugas sebagai penghubung antara HTTP dan logika bisnis.
📁 app/Http/Controllers/ArticleController.php
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreArticleRequest;
use App\Models\Article;
use App\Services\ArticleService;
use Illuminate\Http\JsonResponse;
class ArticleController extends Controller
{
public function __construct(
private ArticleService $articleService // ← DI bekerja di sini
) {}
public function index(): JsonResponse
{
// Controller hanya meneruskan — tidak ada logika bisnis di sini
return response()->json(
$this->articleService->getPublished()
);
}
public function store(StoreArticleRequest $request): JsonResponse
{
// Form Request sudah validasi sebelum method ini dipanggil
$article = $this->articleService->create(
$request->validated(),
$request->user()
);
return response()->json($article, 201);
}
public function show(Article $article): JsonResponse
{
// Route Model Binding sudah resolve Article-nya
return response()->json($article->load('user', 'comments'));
}
}
📌 Perhatikan tiga hal yang sudah “diselesaikan” sebelum controller method dipanggil: (1) middleware sudah cek autentikasi, (2) Form Request sudah validasi input, (3) Route Model Binding sudah ambil data dari DB. Controller tinggal fokus pada logika bisnis level tinggi.
8️⃣ Response — Tamu Pulang
Setelah controller selesai, hasilnya dikembalikan sebagai Response object. Response ini lalu berjalan balik melewati semua middleware (dari dalam ke luar) sebelum akhirnya dikirim ke browser.
// Beberapa cara membuat response di Laravel:
// JSON response — paling umum untuk API
return response()->json($article, 201);
// Response dengan header tambahan
return response()->json($article)
->header('X-Article-Id', $article->id);
// Response dengan cookie
return response()->json($data)
->cookie('last_read', $article->id, 60);
Setelah $response->send() di public/index.php tadi, Laravel memanggil $kernel->terminate() — ini untuk menjalankan pekerjaan yang bisa ditunda: logging, cleanup, atau middleware yang butuh jalan setelah response dikirim (seperti mencatat waktu eksekusi).
🗺️ Gambaran Besar — Perjalanan Lengkap
Ini keseluruhan perjalanan dengan nama file aslinya:
Browser kirim: GET /api/articles/5
↓
public/index.php
→ autoload Composer
→ cek maintenance mode
→ bootstrap aplikasi
↓
bootstrap/app.php
→ bangun IoC Container
→ daftarkan route, middleware, exception handler
↓
Service Providers (semua register() dulu, baru boot())
→ AppServiceProvider, AuthServiceProvider, dll
↓
HTTP Kernel
→ jalankan global middleware
→ tentukan middleware group (web / api)
↓
Middleware Stack (masuk berurutan)
→ HandleCors → ThrottleRequests → auth:sanctum → EnsureUserIsAuthor
↓
Router
→ cocokkan GET /api/articles/5 → ArticleController@show
→ Route Model Binding: ambil Article::findOrFail(5) dari DB
↓
ArticleController@show(Article $article)
→ IoC Container inject ArticleService lewat constructor
→ return response()->json($article)
↓
Middleware Stack (keluar berurutan, kebalikan dari masuk)
→ EnsureUserIsAuthor ← auth:sanctum ← ThrottleRequests ← HandleCors
↓
public/index.php: $response->send()
→ HTTP response dikirim ke browser
↓
$kernel->terminate()
→ logging, cleanup
🧭 Urutan Eksekusi — Ringkasan
| # | Tahap | File | Yang Dilakukan |
|---|---|---|---|
| 1 | Entry point | public/index.php | Tangkap request, bootstrap app |
| 2 | Bootstrap | bootstrap/app.php | Bangun container, daftarkan konfigurasi |
| 3 | Service Provider | app/Providers/*.php | Siapkan binding dan fasilitas |
| 4 | HTTP Kernel | app/Http/Kernel.php | Tentukan middleware yang dijalankan |
| 5 | Middleware (masuk) | app/Http/Middleware/*.php | Auth, throttle, CSRF, dll |
| 6 | Router | routes/api.php | Cocokkan URL ke controller |
| 7 | Controller | app/Http/Controllers/*.php | Jalankan logika, buat response |
| 8 | Middleware (keluar) | app/Http/Middleware/*.php | Modifikasi response kalau perlu |
| 9 | Terminate | public/index.php | Kirim response, cleanup |
💡 Ingat!
Waktu pertama kali belajar ini, wajar kalau rasanya “ngapain harus tahu ini semua? Toh Laravel jalan sendiri.”
Jawabannya: karena begitu ada bug yang aneh, atau middleware yang tidak jalan sesuai harapan, atau exception yang muncul dari tempat yang tidak terduga — kamu butuh gambaran ini untuk tahu di mana harus mulai mencari.
Laravel memang bekerja dengan banyak “keajaiban” di balik layar. Tapi keajaiban itu punya urutan yang jelas dan konsisten. Setelah kamu tahu urutannya, debugging jadi jauh lebih terarah — dan kamu tidak lagi menebak-nebak.
Kalau kamu pernah debugging Laravel dan bingung “ini kode dijalankan dari mana? Siapa yang manggil ini duluan?” — itu pertanda kamu belum punya gambaran besar tentang lifecycle-nya.
Artikel ini akan tunjukkan perjalanan lengkap sebuah HTTP request: dari detik pertama browser ngirim request, sampai detik terakhir Laravel ngirim response balik. Bukan hanya teorinya — kita akan lihat file-file nyata yang terlibat dan apa yang mereka lakukan.
Analoginya satu dulu, baru kita ikutin perjalanannya tahap demi tahap.
🏨 Analogi Besar — Hotel
Bayangin sebuah hotel bintang lima.
Tamu datang → disambut pintu utama → dicek di resepsionis → diantarkan lewat koridor tertentu → sampai di kamar yang sesuai → pelayanan dilakukan → tamu puas dan pulang.
Di balik layar, ada manajer hotel yang sudah menyiapkan semua fasilitas sebelum hotel buka: lampu dinyalakan, staf ditempatkan, SOP dijalankan.
Request di Laravel persis seperti itu:
Browser (tamu)
↓
public/index.php (pintu utama)
↓
bootstrap/app.php (manajer hotel)
↓
HTTP Kernel (resepsionis)
↓
Middleware (pemeriksaan di koridor)
↓
Router (penunjuk arah ke kamar)
↓
Controller → Service → Model (kamar + pelayanan)
↓
Response (tamu pulang)
Sekarang kita buka satu per satu.
1️⃣ public/index.php — Pintu Utama
Semua request HTTP yang masuk ke Laravel — tanpa terkecuali — melewati satu file ini. Tidak ada jalan lain.
📁 public/index.php
<?php
// Memuat autoloader Composer — supaya semua kelas PHP bisa ditemukan
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Cek apakah aplikasi sedang dalam maintenance mode
// Kalau iya, tampilkan halaman maintenance dan berhenti di sini
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Autoloader Composer — mendaftarkan semua kelas dari vendor/ dan app/
require __DIR__.'/../vendor/autoload.php';
// Bootstrap aplikasi — ini yang membangun "hotel"-nya
$app = require_once __DIR__.'/../bootstrap/app.php';
// Tangkap request HTTP yang datang dari browser
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Request::capture() // ← request dari browser ditangkap di sini
);
$response->send(); // ← response dikirim balik ke browser
$kernel->terminate($request, $response); // ← cleanup setelah response dikirim
📌 File ini pendek dan tidak pernah kamu sentuh — tapi dia adalah titik masuk tunggal untuk semua request. Web server (Nginx/Apache) dikonfigurasi untuk selalu mengarahkan semua request ke file ini.
2️⃣ bootstrap/app.php — Manajer Hotel
Setelah pintu dibuka, giliran manajer yang bekerja: menyiapkan semua fondasi aplikasi sebelum request diproses.
📁 bootstrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
$app = Application::create(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php', // ← daftarkan file route web
api: __DIR__.'/../routes/api.php', // ← daftarkan file route API
commands: __DIR__.'/../routes/console.php',
)
->withMiddleware(function (Middleware $middleware) {
// ← konfigurasi middleware global di sini
})
->withExceptions(function (Exceptions $exceptions) {
// ← konfigurasi bagaimana exception ditangani
});
return $app;
📌 Di sinilah IoC Container (yang kita bahas di artikel DI) pertama kali hidup. Semua binding, service provider, dan konfigurasi dasar diinisialisasi di tahap ini. Kalau hotel adalah aplikasi, bootstrap/app.php adalah momen semua lampu dinyalakan dan semua staf disiapkan.
3️⃣ Service Provider — Staf yang Disiapkan Sebelum Buka
Sebelum request pertama masuk, Laravel menjalankan semua Service Provider yang terdaftar. Ini adalah tahap di mana semua “fasilitas hotel” disiapkan.
Setiap Service Provider punya dua method:
register() → daftarkan binding ke IoC Container
boot() → jalankan sesuatu setelah semua provider selesai register
📁 app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Contracts\CommentRepositoryInterface;
use App\Repositories\EloquentCommentRepository;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Tahap ini: daftarkan ke container
// "Kalau ada yang minta CommentRepositoryInterface, beri EloquentCommentRepository"
$this->app->bind(
CommentRepositoryInterface::class,
EloquentCommentRepository::class
);
}
public function boot(): void
{
// Tahap ini: semua provider sudah register, aman untuk akses service lain
// Contoh: daftarkan observer, gate, macro, view composer
\App\Models\Article::observe(\App\Observers\ArticleObserver::class);
}
}
📌 Urutan penting: semua register() dari semua provider dijalankan dulu, baru semua boot() dijalankan. Kenapa? Karena boot() boleh menggunakan binding yang didaftarkan provider lain — dan kalau urutannya terbalik, binding itu belum tentu ada.
4️⃣ HTTP Kernel — Resepsionis
Setelah aplikasi siap, request diserahkan ke HTTP Kernel. Ini adalah pusat komando yang menentukan apa saja yang harus dilalui sebuah request sebelum sampai ke controller.
📁 app/Http/Kernel.php (sebelum Laravel 11)
protected $middleware = [
// Middleware yang dijalankan untuk SEMUA request, tanpa terkecuali
\Illuminate\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Http\Middleware\ValidatePostSize::class,
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
];
protected $middlewareGroups = [
// Middleware untuk request dari browser (cookie, session, CSRF)
'web' => [
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
// Middleware untuk API (stateless, pakai token)
'api' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
📌 Ada tiga lapis middleware di Kernel: global (semua request), group (web atau api), dan route-specific (hanya route tertentu). Request melewati ketiganya secara berurutan — seperti tamu yang harus lewat metal detector, lalu cek reservasi, lalu kunci kamar.
5️⃣ Middleware — Koridor Pemeriksaan
Middleware adalah kode yang berjalan sebelum dan/atau sesudah request sampai ke controller. Setiap middleware punya satu tanggung jawab spesifik.
Struktur middleware selalu sama:
public function handle(Request $request, Closure $next): Response
{
// Kode di sini → dijalankan SEBELUM request sampai ke controller
$response = $next($request); // ← request diteruskan ke middleware berikutnya
// Kode di sini → dijalankan SESUDAH controller selesai
return $response;
}
Contoh nyata — middleware untuk memastikan user sudah login sebelum bisa buat artikel:
📁 app/Http/Middleware/EnsureUserIsAuthor.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAuthor
{
public function handle(Request $request, Closure $next): Response
{
// Cek sebelum request masuk ke controller
if (!$request->user() || $request->user()->role !== 'author') {
return response()->json(['message' => 'Unauthorized.'], 403);
// ← kalau tidak lolos, request BERHENTI di sini, tidak sampai controller
}
// Lolos pemeriksaan → teruskan ke middleware berikutnya atau controller
return $next($request);
}
}
📁 routes/api.php
Middleware dipasang ke route yang membutuhkannya.
Route::middleware(['auth:sanctum', EnsureUserIsAuthor::class])
->group(function () {
Route::post('/articles', [ArticleController::class, 'store']);
Route::put('/articles/{article}', [ArticleController::class, 'update']);
Route::delete('/articles/{article}', [ArticleController::class, 'destroy']);
});
📌 Bayangkan middleware seperti koridor dengan beberapa pos pemeriksaan. Request harus lolos semua pos secara berurutan. Kalau gagal di satu pos, dia dikembalikan langsung — tidak perlu repot sampai ke controller.
Urutan Middleware yang Sebenarnya
Ini yang sering tidak disadari: middleware berjalan seperti tumpukan (stack), bukan antrian lurus.
Middleware A masuk →
Middleware B masuk →
Middleware C masuk →
Controller
← Middleware C keluar
← Middleware B keluar
← Middleware A keluar
Yang pertama masuk adalah yang terakhir keluar. Jadi kalau kamu butuh middleware yang berjalan paling akhir saat response — daftarkan dia paling awal.
6️⃣ Router — Penunjuk Arah
Setelah lolos semua middleware global, request diserahkan ke Router. Router membaca URL dan HTTP method dari request, lalu mencocokkannya dengan daftar route yang sudah didaftarkan.
📁 routes/api.php
<?php
use App\Http\Controllers\ArticleController;
use App\Http\Controllers\CommentController;
// GET /api/articles → ArticleController@index
Route::get('/articles', [ArticleController::class, 'index']);
// GET /api/articles/1 → ArticleController@show, {article} di-resolve otomatis
Route::get('/articles/{article}', [ArticleController::class, 'show']);
// POST /api/articles → butuh auth dulu
Route::middleware('auth:sanctum')->group(function () {
Route::post('/articles', [ArticleController::class, 'store']);
Route::post('/articles/{article}/comments', [CommentController::class, 'store']);
});
Route Model Binding — Fitur Yang Sering Tidak Disadari
Perhatikan {article} di route. Kalau parameter itu di-type-hint dengan Model di controller, Laravel otomatis mengambil data dari database — kamu tidak perlu Article::findOrFail($id) lagi.
📁 app/Http/Controllers/ArticleController.php
// Laravel otomatis query Article::findOrFail($article) sebelum method ini dipanggil
// Kalau tidak ketemu → otomatis 404
public function show(Article $article): JsonResponse
{
return response()->json($article->load('user', 'comments'));
}
📌 Ini namanya Route Model Binding — salah satu fitur Laravel yang paling menghemat kode. Cukup type-hint dengan Model yang sesuai nama parameter route, sisanya diurus otomatis.
7️⃣ Controller → Service → Model — Kamar dan Pelayanan
Request akhirnya sampai ke controller. Di sinilah logika aplikasi dijalankan — dan kalau kamu mengikuti SRP dari artikel SOLID, controller hanya bertugas sebagai penghubung antara HTTP dan logika bisnis.
📁 app/Http/Controllers/ArticleController.php
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreArticleRequest;
use App\Models\Article;
use App\Services\ArticleService;
use Illuminate\Http\JsonResponse;
class ArticleController extends Controller
{
public function __construct(
private ArticleService $articleService // ← DI bekerja di sini
) {}
public function index(): JsonResponse
{
// Controller hanya meneruskan — tidak ada logika bisnis di sini
return response()->json(
$this->articleService->getPublished()
);
}
public function store(StoreArticleRequest $request): JsonResponse
{
// Form Request sudah validasi sebelum method ini dipanggil
$article = $this->articleService->create(
$request->validated(),
$request->user()
);
return response()->json($article, 201);
}
public function show(Article $article): JsonResponse
{
// Route Model Binding sudah resolve Article-nya
return response()->json($article->load('user', 'comments'));
}
}
📌 Perhatikan tiga hal yang sudah “diselesaikan” sebelum controller method dipanggil: (1) middleware sudah cek autentikasi, (2) Form Request sudah validasi input, (3) Route Model Binding sudah ambil data dari DB. Controller tinggal fokus pada logika bisnis level tinggi.
8️⃣ Response — Tamu Pulang
Setelah controller selesai, hasilnya dikembalikan sebagai Response object. Response ini lalu berjalan balik melewati semua middleware (dari dalam ke luar) sebelum akhirnya dikirim ke browser.
// Beberapa cara membuat response di Laravel:
// JSON response — paling umum untuk API
return response()->json($article, 201);
// Response dengan header tambahan
return response()->json($article)
->header('X-Article-Id', $article->id);
// Response dengan cookie
return response()->json($data)
->cookie('last_read', $article->id, 60);
Setelah $response->send() di public/index.php tadi, Laravel memanggil $kernel->terminate() — ini untuk menjalankan pekerjaan yang bisa ditunda: logging, cleanup, atau middleware yang butuh jalan setelah response dikirim (seperti mencatat waktu eksekusi).
🗺️ Gambaran Besar — Perjalanan Lengkap
Ini keseluruhan perjalanan dengan nama file aslinya:
Browser kirim: GET /api/articles/5
↓
public/index.php
→ autoload Composer
→ cek maintenance mode
→ bootstrap aplikasi
↓
bootstrap/app.php
→ bangun IoC Container
→ daftarkan route, middleware, exception handler
↓
Service Providers (semua register() dulu, baru boot())
→ AppServiceProvider, AuthServiceProvider, dll
↓
HTTP Kernel
→ jalankan global middleware
→ tentukan middleware group (web / api)
↓
Middleware Stack (masuk berurutan)
→ HandleCors → ThrottleRequests → auth:sanctum → EnsureUserIsAuthor
↓
Router
→ cocokkan GET /api/articles/5 → ArticleController@show
→ Route Model Binding: ambil Article::findOrFail(5) dari DB
↓
ArticleController@show(Article $article)
→ IoC Container inject ArticleService lewat constructor
→ return response()->json($article)
↓
Middleware Stack (keluar berurutan, kebalikan dari masuk)
→ EnsureUserIsAuthor ← auth:sanctum ← ThrottleRequests ← HandleCors
↓
public/index.php: $response->send()
→ HTTP response dikirim ke browser
↓
$kernel->terminate()
→ logging, cleanup
🧭 Urutan Eksekusi — Ringkasan
| # | Tahap | File | Yang Dilakukan |
|---|---|---|---|
| 1 | Entry point | public/index.php | Tangkap request, bootstrap app |
| 2 | Bootstrap | bootstrap/app.php | Bangun container, daftarkan konfigurasi |
| 3 | Service Provider | app/Providers/*.php | Siapkan binding dan fasilitas |
| 4 | HTTP Kernel | app/Http/Kernel.php | Tentukan middleware yang dijalankan |
| 5 | Middleware (masuk) | app/Http/Middleware/*.php | Auth, throttle, CSRF, dll |
| 6 | Router | routes/api.php | Cocokkan URL ke controller |
| 7 | Controller | app/Http/Controllers/*.php | Jalankan logika, buat response |
| 8 | Middleware (keluar) | app/Http/Middleware/*.php | Modifikasi response kalau perlu |
| 9 | Terminate | public/index.php | Kirim response, cleanup |
💡 Ingat!
Waktu pertama kali belajar ini, wajar kalau rasanya “ngapain harus tahu ini semua? Toh Laravel jalan sendiri.”
Jawabannya: karena begitu ada bug yang aneh, atau middleware yang tidak jalan sesuai harapan, atau exception yang muncul dari tempat yang tidak terduga — kamu butuh gambaran ini untuk tahu di mana harus mulai mencari.
Laravel memang bekerja dengan banyak “keajaiban” di balik layar. Tapi keajaiban itu punya urutan yang jelas dan konsisten. Setelah kamu tahu urutannya, debugging jadi jauh lebih terarah — dan kamu tidak lagi menebak-nebak.
Kalau kamu sudah paham OOP dasar dan mulai ngoding Laravel, cepat atau lambat kamu pasti ketemu kata “SOLID”.
Dan biasanya reaksinya satu dari dua hal: “Kedengarannya keren tapi aku nggak ngerti apa maksudnya”, atau “Aku hapal singkatannya tapi nggak tahu kapan pakainya.”
Artikel ini akan ngebahas semua lima prinsip — satu per satu, pakai contoh nyata dari aplikasi blog/CMS sederhana. Kamu akan lihat kode yang melanggar prinsipnya dulu, baru kita perbaiki. Karena menurut aku, cara tercepat paham “kenapa ini penting” adalah melihat sendiri apa yang rusak kalau kita abaikan.
Konteks Studi Kasus
Kita akan bangun fitur-fitur dari sebuah blog sederhana. Ada tiga entitas utama:
- Article — artikel yang ditulis penulis
- Comment — komentar dari pembaca
- User — penulis dan pembaca
Struktur tabelnya seperti ini:
users → id, name, email, role
articles → id, user_id, title, body, status, published_at
comments → id, article_id, user_id, body, is_approved
Oke, kita mulai.
S — Single Responsibility Principle
“Satu kelas, satu alasan untuk berubah.”
Analoginya
Bayangin ada seorang tukang masak di restoran. Dia masak, dia juga yang cuci piring, sekaligus yang kasir, sekaligus yang anter makanan ke meja.
Kalau lagi rame, semua rusak sekaligus. Kalau mau ganti cara kasir (dari cash ke kartu), harus ganggu tukang masaknya dulu.
Itulah masalah kelas yang punya terlalu banyak tanggung jawab.
Kode yang Melanggar
Ini contoh ArticleController yang sering kelihatan di proyek pemula:
class ArticleController extends Controller
{
public function store(Request $request)
{
// Validasi — harusnya bukan urusan controller
$request->validate([
'title' => 'required|min:5',
'body' => 'required|min:50',
'status' => 'required|in:draft,published',
]);
// Logika bisnis — harusnya bukan urusan controller
$article = Article::create([
'user_id' => auth()->id(),
'title' => $request->title,
'body' => $request->body,
'status' => $request->status,
'published_at' => $request->status === 'published' ? now() : null,
]);
// Notifikasi — harusnya JELAS bukan urusan controller
Mail::to('editor@blog.com')->send(new ArticleSubmitted($article));
return response()->json($article, 201);
}
}
Sekarang tanya ke dirimu: berapa alasan kelas ini bisa berubah?
- Kalau aturan validasi berubah
- Kalau logika bisnis pembuatan artikel berubah
- Kalau cara notifikasi berubah (dari email ke Slack misalnya)
Tiga alasan. Artinya tiga tanggung jawab. Ini melanggar SRP.
Kode yang Benar
Kita pisah tiap tanggung jawab ke file masing-masing.
📁 app/Http/Requests/StoreArticleRequest.php
Form Request khusus untuk validasi. Laravel punya fitur ini — manfaatkan.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreArticleRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => 'required|min:5',
'body' => 'required|min:50',
'status' => 'required|in:draft,published',
];
}
}
📁 app/Services/ArticleService.php
Service untuk logika bisnis. Di sinilah “aturan main” fitur artikel tinggal.
<?php
namespace App\Services;
use App\Models\Article;
use App\Models\User;
use App\Notifications\ArticleSubmittedNotification;
class ArticleService
{
public function create(array $data, User $author): Article
{
$article = Article::create([
'user_id' => $author->id,
'title' => $data['title'],
'body' => $data['body'],
'status' => $data['status'],
'published_at' => $data['status'] === 'published' ? now() : null,
]);
// Notifikasi jadi tanggung jawab service, bukan controller
$author->notify(new ArticleSubmittedNotification($article));
return $article;
}
}
📁 app/Http/Controllers/ArticleController.php
Controller sekarang hanya “penghubung” — terima request, lempar ke service, kembalikan response.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreArticleRequest;
use App\Services\ArticleService;
class ArticleController extends Controller
{
public function __construct(
private ArticleService $articleService
) {}
public function store(StoreArticleRequest $request): JsonResponse
{
// validated() sudah otomatis karena pakai Form Request
$article = $this->articleService->create(
$request->validated(),
$request->user()
);
return response()->json($article, 201);
}
}
📌 Sekarang kalau aturan validasi berubah, kamu hanya sentuh StoreArticleRequest. Kalau logika bisnis berubah, kamu hanya sentuh ArticleService. Controller tidak perlu diubah sama sekali.
O — Open/Closed Principle
“Terbuka untuk ditambah, tertutup untuk diubah.”
Analoginya
Bayangin colokan listrik di dinding. Kamu bisa colokkan apapun ke sana — charger HP, kipas, lampu. Colokannya tidak perlu diubah tiap kali kamu beli perangkat baru.
OCP prinsipnya sama: kelas yang sudah ada dan bekerja dengan baik tidak perlu disentuh ketika ada kebutuhan baru. Kamu cukup tambahkan kode baru, bukan ubah yang lama.
Kode yang Melanggar
Katakanlah kita punya fitur notifikasi ketika artikel dipublish. Kita tulis seperti ini:
class ArticlePublishedNotifier
{
public function notify(Article $article, string $channel): void
{
if ($channel === 'email') {
Mail::to($article->user)->send(new ArticlePublishedMail($article));
} elseif ($channel === 'slack') {
Http::post(config('services.slack.webhook'), [
'text' => "Artikel baru: {$article->title}",
]);
}
// Besok kalau ada WhatsApp, harus tambah elseif lagi di SINI
// Artinya kita UBAH kelas yang sudah jalan — berbahaya!
}
}
Setiap kali ada channel notifikasi baru, kamu harus buka dan ubah file ini. Ini melanggar OCP.
Kode yang Benar
Kita pakai interface sebagai kontrak, lalu tiap channel jadi kelas sendiri.
📁 app/Contracts/ArticleNotificationChannel.php
<?php
namespace App\Contracts;
use App\Models\Article;
interface ArticleNotificationChannel
{
public function send(Article $article): void;
}
📁 app/Notifications/Channels/EmailChannel.php
<?php
namespace App\Notifications\Channels;
use App\Contracts\ArticleNotificationChannel;
use App\Mail\ArticlePublishedMail;
use App\Models\Article;
use Illuminate\Support\Facades\Mail;
class EmailChannel implements ArticleNotificationChannel
{
public function send(Article $article): void
{
Mail::to($article->user)->send(new ArticlePublishedMail($article));
}
}
📁 app/Notifications/Channels/SlackChannel.php
<?php
namespace App\Notifications\Channels;
use App\Contracts\ArticleNotificationChannel;
use App\Models\Article;
use Illuminate\Support\Facades\Http;
class SlackChannel implements ArticleNotificationChannel
{
public function send(Article $article): void
{
Http::post(config('services.slack.webhook'), [
'text' => "Artikel baru dipublish: {$article->title}",
]);
}
}
📁 app/Services/ArticlePublishedNotifier.php
Notifier tidak lagi tahu detail tiap channel — dia cukup loop dan panggil
send().
<?php
namespace App\Services;
use App\Contracts\ArticleNotificationChannel;
use App\Models\Article;
class ArticlePublishedNotifier
{
/** @param ArticleNotificationChannel[] $channels */
public function __construct(
private array $channels
) {}
public function notify(Article $article): void
{
foreach ($this->channels as $channel) {
$channel->send($article);
}
}
}
📌 Kalau besok kamu butuh notifikasi WhatsApp, kamu cukup buat WhatsAppChannel baru yang implements ArticleNotificationChannel. ArticlePublishedNotifier tidak perlu disentuh sama sekali. Terbuka untuk ditambah, tertutup untuk diubah.
L — Liskov Substitution Principle
“Subclass harus bisa menggantikan parent-nya tanpa bikin program rusak.”
Analoginya
Bayangin kamu punya remote TV. Semua remote TV — merek apapun — punya tombol Power, Volume, dan Channel. Kalau kamu ganti remote TV lama dengan yang baru, TV-nya harus tetap jalan normal.
Kalau remote baru tiba-tiba bikin TV meledak waktu kamu tekan Volume, itu masalahnya ada di remote — dia tidak bisa menggantikan tugasnya dengan benar.
LSP bilang: subclass tidak boleh “mengkhianati” janji yang sudah dibuat parent-nya.
Kode yang Melanggar
Kita punya abstract class BaseComment dan dua turunannya:
abstract class BaseComment
{
abstract public function approve(): void;
abstract public function getContent(): string;
}
class RegularComment extends BaseComment
{
public function approve(): void
{
$this->is_approved = true;
$this->save();
}
public function getContent(): string
{
return $this->body;
}
}
// MASALAH DI SINI ↓
class SystemComment extends BaseComment
{
public function approve(): void
{
// System comment tidak bisa di-approve — tapi parent-nya menjanjikan bisa!
throw new \Exception("System comments cannot be approved.");
}
public function getContent(): string
{
return $this->message;
}
}
Sekarang kalau ada kode seperti ini:
foreach ($comments as $comment) {
$comment->approve(); // Meledak kalau $comment adalah SystemComment
}
SystemComment tidak bisa menggantikan BaseComment dengan aman — ini melanggar LSP.
Kode yang Benar
Solusinya: pisah kontrak menjadi lebih spesifik. Jangan paksa kelas mewarisi kemampuan yang tidak relevan untuknya.
📁 app/Contracts/ApprovableInterface.php
Hanya komentar yang bisa di-approve yang perlu implements ini.
<?php
namespace App\Contracts;
interface ApprovableInterface
{
public function approve(): void;
public function reject(): void;
}
📁 app/Models/Comment.php
<?php
namespace App\Models;
use App\Contracts\ApprovableInterface;
use Illuminate\Database\Eloquent\Model;
class Comment extends Model implements ApprovableInterface
{
public function approve(): void
{
$this->update(['is_approved' => true]);
}
public function reject(): void
{
$this->update(['is_approved' => false]);
}
}
📁 app/Models/SystemLog.php
Log sistem punya method-nya sendiri — tidak dipaksa punya
approve().
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SystemLog extends Model
{
public function getContent(): string
{
return $this->message;
}
// Tidak ada approve() — karena memang tidak relevan
}
📌 Sekarang kode yang butuh fitur approve hanya akan menerima objek yang implements ApprovableInterface. Tidak ada lagi kemungkinan throw new Exception yang mengejutkan di tengah loop.
I — Interface Segregation Principle
“Jangan paksa kelas implement method yang tidak dia butuhkan.”
Analoginya
Bayangin ada formulir pendaftaran untuk karyawan baru. Di formulir itu ada pertanyaan: “Berapa lama kamu bisa menahan napas dalam air?”
Untuk programmer, pertanyaan itu tidak relevan sama sekali. Tapi mereka tetap harus isi karena formulirnya sama untuk semua orang.
ISP bilang: buat interface yang spesifik, bukan interface raksasa yang isinya terlalu banyak.
Kode yang Melanggar
Kita punya satu interface besar untuk semua aksi yang bisa dilakukan user di blog:
interface UserActionInterface
{
public function writeArticle(): void;
public function editArticle(): void;
public function deleteArticle(): void;
public function approveComment(): void;
public function banUser(): void; // Hanya admin yang bisa
public function manageSettings(): void; // Hanya admin yang bisa
}
Sekarang kelas RegularAuthor dipaksa implement method yang tidak relevan:
class RegularAuthor implements UserActionInterface
{
public function writeArticle(): void { /* oke */ }
public function editArticle(): void { /* oke */ }
public function deleteArticle(): void { /* oke */ }
public function approveComment(): void { /* oke */ }
// Dipaksa implement ini, padahal tidak masuk akal
public function banUser(): void
{
throw new \Exception("Regular authors cannot ban users.");
}
public function manageSettings(): void
{
throw new \Exception("Regular authors cannot manage settings.");
}
}
Kode yang Benar
Pecah interface besar tadi menjadi interface-interface kecil yang spesifik.
📁 app/Contracts/CanWriteArticle.php
<?php
namespace App\Contracts;
interface CanWriteArticle
{
public function writeArticle(): void;
public function editArticle(): void;
public function deleteArticle(): void;
}
📁 app/Contracts/CanModerateContent.php
<?php
namespace App\Contracts;
interface CanModerateContent
{
public function approveComment(): void;
public function rejectComment(): void;
}
📁 app/Contracts/CanAdminister.php
<?php
namespace App\Contracts;
interface CanAdminister
{
public function banUser(): void;
public function manageSettings(): void;
}
Sekarang tiap role hanya implements apa yang relevan:
📁 app/Models/Author.php
<?php
namespace App\Models;
use App\Contracts\CanWriteArticle;
// Author hanya perlu CanWriteArticle — tidak ada method yang dipaksakan
class Author extends User implements CanWriteArticle
{
public function writeArticle(): void { /* ... */ }
public function editArticle(): void { /* ... */ }
public function deleteArticle(): void { /* ... */ }
}
📁 app/Models/Admin.php
<?php
namespace App\Models;
use App\Contracts\CanAdminister;
use App\Contracts\CanModerateContent;
use App\Contracts\CanWriteArticle;
// Admin bisa melakukan semuanya — implements semua yang relevan
class Admin extends User implements CanWriteArticle, CanModerateContent, CanAdminister
{
public function writeArticle(): void { /* ... */ }
public function editArticle(): void { /* ... */ }
public function deleteArticle(): void { /* ... */ }
public function approveComment(): void { /* ... */ }
public function rejectComment(): void { /* ... */ }
public function banUser(): void { /* ... */ }
public function manageSettings(): void { /* ... */ }
}
📌 Author tidak tahu soal banUser(). Admin punya segalanya. Dan kalau besok kamu buat role Moderator yang cuma bisa moderasi komentar, kamu tinggal implements CanModerateContent saja — tanpa mewarisi method yang tidak perlu.
D — Dependency Inversion Principle
“Kelas bergantung pada abstraksi, bukan pada implementasi konkret.”
Analoginya
Bayangin lampu di rumahmu. Lampu itu tidak “bergantung” pada PLN secara langsung. Dia bergantung pada colokan listrik — sebuah standar abstrak.
Karena itu, kamu bisa pasang solar panel di rumah, dan lampunya tetap nyala tanpa perlu ganti lampunya. Yang berubah hanya sumber listriknya — bukan lampunya.
DIP bilang: kelas kamu harus bergantung pada interface/abstraksi, bukan pada class konkret tertentu. Karena kalau langsung bergantung pada implementasi, ganti implementasi = ubah semua kode yang bergantung padanya.
Kode yang Melanggar
CommentService langsung bergantung pada implementasi konkret database:
class CommentService
{
// Langsung bikin object Eloquent di dalam service
// Artinya CommentService "tahu" dan bergantung pada Eloquent
public function getUnapprovedComments(): Collection
{
return Comment::where('is_approved', false)->get();
}
public function approve(int $commentId): void
{
$comment = Comment::findOrFail($commentId);
$comment->update(['is_approved' => true]);
}
}
Masalahnya: kalau kamu mau test CommentService tanpa database sungguhan, tidak bisa. Dan kalau besok kamu ganti cara ambil data (misalnya dari cache atau API), kamu harus ubah CommentService langsung.
Kode yang Benar
Kita buat interface sebagai “colokan” — lalu CommentService bergantung pada interface itu, bukan pada Eloquent.
📁 app/Contracts/CommentRepositoryInterface.php
<?php
namespace App\Contracts;
use Illuminate\Support\Collection;
interface CommentRepositoryInterface
{
public function getUnapproved(): Collection;
public function approve(int $commentId): void;
public function findOrFail(int $commentId): mixed;
}
📁 app/Repositories/EloquentCommentRepository.php
Implementasi konkret yang menggunakan Eloquent.
<?php
namespace App\Repositories;
use App\Contracts\CommentRepositoryInterface;
use App\Models\Comment;
use Illuminate\Support\Collection;
class EloquentCommentRepository implements CommentRepositoryInterface
{
public function getUnapproved(): Collection
{
return Comment::where('is_approved', false)->get();
}
public function approve(int $commentId): void
{
$comment = $this->findOrFail($commentId);
$comment->update(['is_approved' => true]);
}
public function findOrFail(int $commentId): Comment
{
return Comment::findOrFail($commentId);
}
}
📁 app/Services/CommentService.php
Service sekarang bergantung pada interface, bukan Eloquent langsung.
<?php
namespace App\Services;
use App\Contracts\CommentRepositoryInterface;
use Illuminate\Support\Collection;
class CommentService
{
// Bergantung pada ABSTRAKSI (interface), bukan implementasi konkret
public function __construct(
private CommentRepositoryInterface $commentRepository
) {}
public function getUnapprovedComments(): Collection
{
return $this->commentRepository->getUnapproved();
}
public function approve(int $commentId): void
{
$this->commentRepository->approve($commentId);
}
}
📁 app/Providers/AppServiceProvider.php
Di sinilah kita “sambungkan” interface ke implementasinya — satu baris binding.
public function register(): void
{
$this->app->bind(
\App\Contracts\CommentRepositoryInterface::class,
\App\Repositories\EloquentCommentRepository::class
);
}
📌 Sekarang kalau kamu mau test CommentService, tinggal buat FakeCommentRepository yang implements interface yang sama — tanpa sentuh database sungguhan. Dan kalau kamu mau ganti ke cache atau API, cukup ganti satu baris di AppServiceProvider. CommentService tidak perlu tahu apa yang berubah.
🗺️ Gambaran Besar — Semuanya Bersama
Ini bagaimana semua file dari kelima prinsip duduk di struktur Laravel:
app/
├── Contracts/
│ ├── ArticleNotificationChannel.php ← OCP: kontrak channel notifikasi
│ ├── ApprovableInterface.php ← LSP: kontrak untuk yang bisa di-approve
│ ├── CanWriteArticle.php ← ISP: kontrak spesifik per kemampuan
│ ├── CanModerateContent.php ← ISP
│ ├── CanAdminister.php ← ISP
│ └── CommentRepositoryInterface.php ← DIP: kontrak repository
│
├── Http/
│ ├── Controllers/
│ │ └── ArticleController.php ← SRP: hanya urusan HTTP
│ └── Requests/
│ └── StoreArticleRequest.php ← SRP: hanya urusan validasi
│
├── Services/
│ ├── ArticleService.php ← SRP: logika bisnis artikel
│ ├── ArticlePublishedNotifier.php ← OCP: kirim ke semua channel
│ └── CommentService.php ← DIP: bergantung pada interface
│
├── Repositories/
│ └── EloquentCommentRepository.php ← DIP: implementasi konkret
│
├── Notifications/
│ └── Channels/
│ ├── EmailChannel.php ← OCP: channel spesifik
│ └── SlackChannel.php ← OCP: channel spesifik
│
└── Models/
├── Comment.php ← LSP: implements ApprovableInterface
├── Author.php ← ISP: hanya CanWriteArticle
└── Admin.php ← ISP: semua kemampuan
🧭 Ringkasan — SOLID dalam Satu Kalimat
| Prinsip | Satu Kalimat | Sinyal Pelanggaran |
|---|---|---|
| S — Single Responsibility | Satu kelas, satu alasan berubah | Controller yang validasi + bisnis + notifikasi |
| O — Open/Closed | Tambah fitur dengan kode baru, bukan ubah yang lama | if/elseif panjang untuk tipe yang terus bertambah |
| L — Liskov Substitution | Subclass tidak boleh “mengkhianati” janji parent | Method yang throw new Exception karena tidak relevan |
| I — Interface Segregation | Interface kecil dan spesifik, bukan interface raksasa | Class yang implement method kosong atau throw Exception |
| D — Dependency Inversion | Bergantung pada abstraksi, bukan implementasi | new ClassName() di dalam service atau controller |
💡 Ingat!
Waktu pertama kali belajar SOLID, wajar kalau rasanya “ribet banget, bikin interface segitu banyak untuk hal sesederhana ini?”
Jawabannya: untuk aplikasi kecil, memang tidak perlu semua ini. Tapi begitu timmu tumbuh, fiturnya bertambah, dan kamu harus ganti library payment atau tambah channel notifikasi — kamu akan sangat bersyukur sudah punya abstraksi yang jelas.
SOLID bukan tentang menulis lebih banyak kode. Ini tentang menulis kode yang mudah diganti tanpa takut merusak hal lain. Di dunia nyata, itu nilainya sangat besar.
Kalau kamu baru belajar OOP, dua hal ini pasti bakal bikin kepala pusing duluan — bukan karena susah, tapi karena hampir semua penjelasan di internet langsung loncat ke kode tanpa ngasih gambaran besarnya dulu.
Jadi di artikel ini, kita mulai dari analogi dulu. Baru setelah ngerti konsepnya, kita masuk ke kode.
Masalah yang Mereka Selesaikan
Bayangin kamu lagi bangun aplikasi manajemen data. Di aplikasi ini ada fitur ekspor laporan, dan kamu mau support tiga format: PDF, Excel, dan CSV.
Masing-masing format cara pembuatannya beda. Tapi semua harus bisa melakukan dua hal yang sama:
generate()— untuk membuat file-nyadownload()— untuk mengirim file ke browser
Nah, di sinilah masalahnya muncul.
Kalau kamu bikin 3 kelas terpisah tanpa aturan apapun, bisa-bisa PdfExporter punya method bernama generate(), tapi ExcelExporter malah namanya build(), dan CsvExporter namanya create(). Semua bikin hal yang sama tapi dengan nama berbeda.
Ini yang disebut kode tidak konsisten — dan ini mimpi buruk kalau aplikasi makin besar.
Interface dan Abstract Class adalah solusinya. Keduanya adalah cara untuk bilang ke semua kelas: “Hei, kamu harus punya method ini. Titik.”
Interface — “Daftar Aturan Kosong”
Analoginya
Bayangin Interface seperti formulir kontrak kerja.
Di kontrak itu tertulis: “Kamu wajib bisa menyetir mobil, wajib bisa berbahasa Inggris, dan wajib punya SIM A.”
Kontrak tidak peduli kamu belajar nyetir dari siapa, les bahasa Inggris di mana, atau ujian SIM-nya di kota mana. Yang penting: hasilnya ada, dan bisa dibuktikan.
Interface di PHP persis seperti itu — dia cuma mendaftarkan apa yang harus bisa dilakukan, tanpa peduli bagaimana caranya.
Aturan Interface
- Semua method di dalam interface tidak boleh punya isi (hanya nama dan parameternya saja).
- Kelas yang pakai interface (
implements) wajib mengisi semua method yang didaftarkan. - Satu kelas boleh
implementslebih dari satu interface sekaligus.
Kodenya
<?php
namespace App\Contracts;
interface ExporterInterface
{
// Hanya daftar method — tidak ada isi sama sekali
public function generate(array $data): string;
public function download(string $filename): void;
}📌 Perhatikan — di dalam interface tidak ada kurung kurawal { } berisi logika. Hanya ada nama method, parameter, dan tipe return-nya. Itu saja. Ini namanya method signature.
Abstract Class — “Fondasi Setengah Jadi”
Analoginya
Sekarang bayangin Abstract Class seperti rumah yang sudah dibangun setengah jadi oleh developer perumahan.
Fondasi sudah ada. Dinding sudah ada. Atap sudah ada. Listrik dan air sudah nyambung.
Tapi beberapa hal sengaja dibiarkan kosong — desain interior, warna cat, jenis lantai. Kenapa? Karena itu terserah pembelinya masing-masing.
Developer bilang: “Bagian ini kamu yang harus isi sendiri. Bagian lainnya sudah aku siapkan, tinggal pakai.”
Abstract Class di PHP persis seperti itu.
Aturan Abstract Class
- Boleh punya method yang sudah ada isinya (siap pakai oleh subclass).
- Boleh punya method
abstract— yang wajib diisi oleh subclass. - Boleh punya property (variabel seperti
$disk). - Tidak bisa di-
newlangsung — hanya bisa dipakai lewat subclass. - Satu kelas hanya boleh
extendssatu abstract class.
Kodenya
<?php
namespace App\Services;
use App\Contracts\ExporterInterface;
use Illuminate\Support\Facades\Storage;
abstract class BaseExporter implements ExporterInterface
{
// Property yang bisa dipakai oleh semua subclass
protected string $disk = 'local';
// Method ini SUDAH ADA ISINYA — semua format pakai logika download yang sama:
// simpan file sementara → kirim ke browser → hapus file
public function download(string $filename): void
{
$path = "exports/{$filename}";
Storage::disk($this->disk)->put($path, $this->tempFile);
response()->download(
Storage::disk($this->disk)->path($path)
)->deleteFileAfterSend()->send();
}
// Method ini SENGAJA DIKOSONGKAN — tiap format cara generate-nya beda:
// PDF pakai library berbeda dengan Excel, Excel berbeda dengan CSV
abstract public function generate(array $data): string;
}📌 Perhatikan keyword abstract di depan method generate. Itu artinya: “Method ini wajib diisi oleh siapapun yang extends class ini. Kalau tidak diisi, PHP akan langsung error.”
Concrete Class — “Produk Jadinya”
Ini adalah kelas yang benar-benar dipakai di aplikasi. Dia extends abstract class dan mengisi semua method yang masih kosong.
📁 app/Services/PdfExporter.php
<?php
namespace App\Services;
use Barryvdh\DomPDF\Facade\Pdf;
class PdfExporter extends BaseExporter
{
// Wajib diisi karena BaseExporter mendeklarasikannya sebagai abstract
public function generate(array $data): string
{
// Logika khusus PDF — pakai library DomPDF
$pdf = Pdf::loadView('exports.report', ['data' => $data]);
$this->tempFile = $pdf->output();
return $this->tempFile;
}
// Method download() tidak perlu ditulis ulang —
// sudah diwarisi dari BaseExporter
}📁 app/Services/ExcelExporter.php
<?php
namespace App\Services;
use Maatwebsite\Excel\Facades\Excel;
class ExcelExporter extends BaseExporter
{
public function generate(array $data): string
{
// Logika khusus Excel — pakai library Maatwebsite
// Cara generate-nya sama sekali beda dengan PDF, tapi nama method-nya sama
$this->tempFile = Excel::raw(new ReportExport($data), \Maatwebsite\Excel\Excel::XLSX);
return $this->tempFile;
}
}📌 ExcelExporter tidak perlu nulis ulang download() karena sudah diwarisi dari BaseExporter. Yang berbeda hanya cara generate()-nya — dan memang itulah satu-satunya yang berbeda antar format.
Gambaran Besar — Posisi Tiap File
Ini gambaran keseluruhan struktur dan hubungannya:
app/
├── Contracts/
│ └── ExporterInterface.php ← "Daftar aturan" — interface
│
├── Services/
│ ├── BaseExporter.php ← "Fondasi" — abstract class
│ ├── PdfExporter.php ← "Produk jadi" — concrete class
│ ├── ExcelExporter.php ← concrete class lain
│ └── CsvExporter.php ← concrete class lainAlur warisannya:
ExporterInterface
↓ (implements)
BaseExporter
↓ (extends)
PdfExporter / ExcelExporter / CsvExporterJadi, Kapan Pakai Yang Mana?
Pakai Interface ketika: Kamu cuma mau bilang “kelas ini harus bisa melakukan X dan Y” — tanpa peduli bagaimana caranya dan tanpa ada logika bersama sama sekali.
Pakai Abstract Class ketika: Ada logika yang sama persis di beberapa kelas, dan kamu malas nulis ulang di tiap kelas. Tapi beberapa bagian tetap harus diisi sendiri.
Pakai keduanya (paling umum di Laravel) ketika: Interface sebagai kontrak publik, Abstract Class sebagai fondasi bersama, Concrete Class sebagai implementasi spesifik.
📊 Perbandingan Singkat
| Interface | Abstract Class | |
|---|---|---|
| Bisa punya isi method | ❌ | ✅ |
| Bisa punya property | ❌ | ✅ |
| Satu kelas bisa pakai lebih dari satu | ✅ | ❌ |
Bisa di-new langsung | ❌ | ❌ |
| Tujuan utama | Kontrak/aturan | Fondasi bersama |
💡 Ingat!Waktu pertama kali belajar ini, wajar kalau masih bingung “kenapa tidak bikin class biasa saja?”— Ini bukan tentang benar atau salah. Ini tentang kode yang mudah dirawat jangka panjang.
Jawabannya: boleh saja. Tapi begitu aplikasi makin besar dan kamu punya banyak format ekspor — atau besok diminta tambah format baru — kamu akan sangat bersyukur ada interface yang memastikan semuanya konsisten, dan ada abstract class yang memastikan kamu tidak copy-paste logikadownload()yang sama ke mana-mana.