Banyak developer yang baru pertama kali dengar soal testing langsung punya reaksi yang sama: “Nanti aja, yang penting fiturnya jalan dulu.”
Dan memang, testing tidak membuat fitur jalan lebih cepat. Tapi suatu hari, kamu refactor satu bagian kecil dari kode — dan tiga fitur lain di tempat yang tidak ada hubungannya tiba-tiba rusak. Kalau ada test, kamu tahu dalam hitungan detik. Kalau tidak ada, kamu tahu saat user laporan.
Artikel ini bahas tiga jenis test yang ada di Laravel, kapan masing-masing dipakai, dan bagaimana menangani satu masalah klasik: bagaimana test sebuah fitur yang bergantung pada service eksternal yang tidak bisa kamu kontrol.
🤔 Satu Prinsip Sebelum Mulai
Sebelum masuk ke kode, ada dua cara pikir soal testing yang perlu diluruskan:
❌ “Kalau test hijau, berarti pasti benar.”
✅ “Kalau test hijau, kemungkinan besar benar. Kalau merah, pasti ada yang salah.”
Testing bukan jaminan kebenaran mutlak. Test adalah kode juga — dan kode bisa bug. Yang test lakukan adalah menurunkan risiko, bukan menghilangkannya.
Dan satu prinsip lagi yang sering dilanggar: test harus lebih bodoh dari kode yang ditest. Artinya, test tidak boleh meniru logika dari kode yang ingin dicek. Test cukup berupa input dan output yang diharapkan. Kalau test ikut berhitung diskon, siapa yang mengecek kalau perhitungannya salah?
🔬 Unit Test — “Tes Satu Komponen, Sendirian”
Analoginya
Bayangin kamu beli mesin kopi baru. Sebelum dirangkai, kamu tes masing-masing komponen: apakah grinder-nya mau berputar? Apakah pompa airnya mau mengalir? Apakah heater-nya mau panas?
Setiap komponen ditest sendirian, tanpa bergantung pada komponen lain. Kalau grinder rusak, kamu langsung tahu — tidak perlu nunggu kopi jadinya terasa aneh.
Unit Test di Laravel persis seperti itu. Dia menguji satu method atau class secara terisolasi — tanpa database, tanpa HTTP, tanpa service eksternal.
Karakteristiknya
- Tidak akses database
- Tidak lewat HTTP request
- Tidak load service eksternal
- Semua dependency yang tidak relevan di-mock
- Sangat cepat — ratusan test bisa selesai dalam hitungan detik
Kodenya
Pertama, buat logic yang mau ditest sebagai class terpisah. Ini kunci penting — logic yang tertanam di Controller tidak bisa ditest unit secara bersih.
📁 app/Services/DiscountService.php
<?php
namespace App\Services;
class DiscountService
{
public function apply(int $price, int $discount): int
{
return $price - ($price * $discount / 100);
}
}
php artisan make:test DiscountServiceTest --unit
📁 tests/Unit/DiscountServiceTest.php
<?php
namespace Tests\Unit;
use App\Services\DiscountService;
use PHPUnit\Framework\TestCase;
class DiscountServiceTest extends TestCase
{
public function test_discount_is_applied_correctly(): void
{
$service = new DiscountService();
// Input: harga 100, diskon 10%
$result = $service->apply(100, 10);
// Output yang diharapkan: 90
$this->assertEquals(90, $result);
}
public function test_zero_discount_returns_original_price(): void
{
$service = new DiscountService();
$result = $service->apply(100, 0);
$this->assertEquals(100, $result);
}
public function test_full_discount_returns_zero(): void
{
$service = new DiscountService();
$result = $service->apply(100, 100);
$this->assertEquals(0, $result);
}
}
📌 Perhatikan — tidak ada setUp() yang kompleks, tidak ada database, tidak ada $this->artisan(...). Cuma buat instance, panggil method, cek hasilnya. Sesederhana itu.
Mental model: “Kalau logika ini dipanggil sendirian, apakah hasilnya benar?”
🌐 Feature Test — “Tes Satu Fitur dari Mata User”
Analoginya
Kembali ke analogi mesin kopi — setelah semua komponen lulus tes, sekarang kamu rangkai semuanya dan tes: “Kalau aku masukin kopi dan air, apakah kopinya keluar dengan benar?”
Kamu tidak lagi cek satu komponen sendirian. Kamu cek satu alur dari awal sampai akhir — seperti yang dilakukan user sungguhan.
Feature Test di Laravel bekerja seperti itu. Dia mensimulasikan HTTP request dari luar, melewati semua lapisan — middleware, validation, controller, service — dan mengecek apakah responsenya benar.
Karakteristiknya
- Mensimulasikan HTTP request (tapi tidak benar-benar lewat network)
- Menyentuh database (dengan
RefreshDatabasetrait, database di-reset tiap test) - Tidak bergantung pada service eksternal — kalau ada, di-mock
- Lebih lambat dari Unit Test, tapi masih jauh lebih cepat dari manual testing
Kodenya
php artisan make:test DiscountApiTest
📁 tests/Feature/DiscountApiTest.php
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DiscountApiTest extends TestCase
{
use RefreshDatabase;
public function test_discount_api_returns_correct_final_price(): void
{
$response = $this->postJson('/api/discount', [
'price' => 100,
'discount' => 10,
]);
$response
->assertStatus(200)
->assertJson([
'final' => 90,
]);
}
public function test_discount_api_requires_price(): void
{
$response = $this->postJson('/api/discount', [
'discount' => 10,
// 'price' sengaja tidak dikirim
]);
// Validasi harus reject request ini
$response->assertStatus(422);
}
public function test_authenticated_user_can_access_discount(): void
{
$user = User::factory()->create();
// Simulasi user sudah login
$response = $this->actingAs($user)
->postJson('/api/discount', [
'price' => 200,
'discount' => 25,
]);
$response
->assertStatus(200)
->assertJson(['final' => 150]);
}
}
📌 Yang ditest bukan hanya angkanya — tapi juga bahwa route-nya benar, middleware berjalan, validasi bekerja, dan response punya format yang tepat. Satu test bisa menangkap banyak hal sekaligus.
Mental model: “Kalau user memakai fitur ini, apakah semuanya berhasil?”
🔗 Integration Test — “Tes Apakah Komponen Bisa Kerja Bareng”
Analoginya
Mesin kopi sudah beres. Tapi sekarang kamu sambungkan ke sistem kelistrikan rumah, ke sistem filter air, dan ke aplikasi timer otomatis. Pertanyaannya berbeda: “Apakah semua sistem ini bisa bekerja bersama dengan benar?”
Integration Test bukan lagi soal apakah satu fitur berjalan dari sudut pandang user. Ini soal apakah komponen nyata — database sungguhan, Redis sungguhan, queue sungguhan — bisa bekerja bersama tanpa ada yang salah konfigurasi.
Karakteristiknya
- Menggunakan database nyata (bukan mock)
- Bisa menyentuh Redis, Queue, atau service eksternal di environment sandbox
- Menangkap error konfigurasi yang tidak terlihat di Unit dan Feature Test
- Paling lambat dari tiga jenis test
- Tidak harus lewat HTTP — bisa langsung tes service dan model
Kodenya
php artisan make:test OrderIntegrationTest
📁 tests/Feature/Integration/OrderIntegrationTest.php
<?php
namespace Tests\Feature\Integration;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_order_is_saved_to_database_with_correct_final_price(): void
{
$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/orders', [
'price' => 100,
'discount' => 10,
]);
// Cek database secara langsung — apakah data benar-benar tersimpan?
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'final_price' => 90,
]);
}
public function test_order_count_increases_after_creation(): void
{
$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/orders', [
'price' => 100,
'discount' => 10,
]);
// Cek jumlah record di database
$this->assertDatabaseCount('orders', 1);
}
}
Ketika postJson('/api/orders', [...]) dieksekusi, yang sebenarnya terjadi adalah seluruh pipeline Laravel berjalan sungguhan:
Test
↓
HTTP Request (fake, tapi melewati pipeline nyata)
↓
Route → Middleware (auth, validation, dll)
↓
Controller → Service (DiscountService)
↓
Model (Order) → Database (insert sungguhan)
↓
assertDatabaseHas() → cek hasil akhir di database nyata
Tidak ada satu komponen pun yang di-mock. Semua bekerja sungguhan — dan itulah yang membuat ini disebut Integration Test.
Mental model: “Apakah komponen-komponen ini benar-benar bisa kerja bareng?”
🗺️ Tiga Jenis Test, Satu Strategi
Ketiganya bukan pilihan — ketiganya dipakai bersama dengan urutan yang logis.
Step 1 — Unit Test
"Logic-nya benar tidak, kalau dipanggil sendirian?"
↓
Step 2 — Feature Test
"Fitur-nya jalan tidak, dari sudut pandang user?"
↓
Step 3 — Integration Test
"Semua komponennya terhubung dengan benar?"
| Unit Test | Feature Test | Integration Test | |
|---|---|---|---|
| Kecepatan | ⚡ Sangat cepat | 🏃 Sedang | 🐢 Paling lambat |
| Database | ❌ Tidak | ✅ (refresh) | ✅ Nyata |
| HTTP | ❌ Tidak | ✅ Simulasi | ✅ Simulasi |
| Mock? | ✅ Semua dep. | ✅ External saja | ❌ Tidak |
| Lokasi | tests/Unit/ | tests/Feature/ | tests/Feature/Integration/ |
| Fokus | Logic | Alur fitur | Integrasi sistem |
Cara Menjalankan Test
# Semua test
php artisan test
# Satu file spesifik
php artisan test tests/Unit/DiscountServiceTest.php
# Satu method spesifik
php artisan test --filter=test_discount_is_applied_correctly
# Dengan output detail
php artisan test --debug
🎭 Mocking Service — “Palsu, Tapi Terkontrol”
Masalahnya
Bayangkan fiturmu bergantung pada Midtrans untuk proses pembayaran. Setiap kali test dijalankan, apa yang terjadi?
- Test mengirim request ke server Midtrans yang sungguhan
- Kamu kena charge per transaksi
- Test gagal kalau internet mati atau API Midtrans sedang down
- Hasilnya tidak bisa diprediksi — Midtrans bisa return berbeda tiap waktu
Ini yang disebut non-deterministic — hasil test bergantung pada pihak luar yang tidak bisa kamu kontrol.
Mocking adalah solusinya: ganti service eksternal asli dengan versi palsu selama testing, yang responnya bisa kamu atur sendiri.
Aturan Emas Sebelum Mock
Ada satu kebiasaan yang harus dihindari:
// ❌ JANGAN — HTTP call langsung di Controller, tidak bisa di-mock dengan bersih
public function pay(Request $request)
{
$response = Http::post('https://api.midtrans.com/charge', [...]);
// ...
}
Bungkus semua service eksternal dalam class sendiri:
// ✅ LAKUKAN — bungkus dalam class, lalu mock class-nya
public function pay(Request $request)
{
$result = app(PaymentGateway::class)->charge($request->amount);
// ...
}
Dengan begini, yang di-mock adalah PaymentGateway — bukan jaringan. Test jadi bersih, predictable, dan cepat.
Mocking HTTP Client — Http::fake()
📁 app/Services/PaymentGateway.php
Class asli yang akan di-mock saat testing.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class PaymentGateway
{
public function charge(int $amount): bool
{
$response = Http::post('https://payment.test/charge', [
'amount' => $amount,
]);
return $response->successful();
}
}
📁 tests/Feature/PaymentTest.php
<?php
namespace Tests\Feature;
use App\Services\PaymentGateway;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PaymentTest extends TestCase
{
public function test_payment_succeeds_when_gateway_returns_200(): void
{
// Instruksikan Http facade untuk tidak benar-benar kirim request
Http::fake([
'payment.test/charge' => Http::response([
'status' => 'success',
], 200),
]);
$gateway = new PaymentGateway();
$result = $gateway->charge(100);
$this->assertTrue($result);
}
public function test_payment_fails_when_gateway_returns_500(): void
{
Http::fake([
'payment.test/charge' => Http::response([
'status' => 'error',
], 500),
]);
$gateway = new PaymentGateway();
$result = $gateway->charge(100);
$this->assertFalse($result);
}
}
📌 Tidak ada HTTP request yang benar-benar dikirim. Http::fake() mengganti HTTP Client di Service Container dengan Fake Client — Guzzle tidak pernah dipanggil, DNS tidak pernah resolve, tidak ada traffic keluar sama sekali.
Bagaimana Http::fake() Bekerja di Balik Layar
Http adalah Facade — bukan class biasa, tapi proxy ke service yang terdaftar di Service Container. Alurnya normal:
Http::post() → Facade → HttpFactory → Guzzle → Network → Server
Saat Http::fake() dipanggil, Laravel mengganti HttpFactory di Container dengan versi palsu:
Http::post() → Facade → HttpFactory (FAKE MODE) → Fake Response → STOP
Guzzle tidak pernah disentuh. Inilah kenapa Http::fake() bisa ada — karena Laravel memang merancang Http Client untuk bisa ditest dengan cara ini.
Fake Facade Lainnya
Laravel menyediakan mekanisme ::fake() untuk Facade-Facade yang sering dipakai saat testing:
// Pastikan email tidak benar-benar terkirim
Mail::fake();
// Pastikan notifikasi tidak benar-benar dikirim
Notification::fake();
// Pastikan job tidak benar-benar diqueue
Queue::fake();
// Pastikan event tidak benar-benar di-fire
Event::fake();
// Pastikan job bus tidak benar-benar dispatch
Bus::fake();
// Pastikan HTTP request tidak benar-benar keluar
Http::fake();
Setelah memanggil fake(), kamu bisa assert bahwa sesuatu seharusnya terjadi — tanpa benar-benar menjalankannya:
public function test_welcome_email_is_sent_after_registration(): void
{
Mail::fake();
$this->postJson('/api/register', [
'name' => 'Budi',
'email' => 'budi@mail.com',
'password' => 'secret123',
]);
// Cek bahwa WelcomeMail memang di-queue untuk dikirim ke Budi
Mail::assertQueued(WelcomeMail::class, function ($mail) {
return $mail->hasTo('budi@mail.com');
});
}
public function test_order_job_is_dispatched_after_payment(): void
{
Queue::fake();
$this->postJson('/api/pay', ['order_id' => 1]);
// Cek bahwa ProcessOrder memang masuk antrian
Queue::assertPushed(ProcessOrder::class);
}
⚠️ Kesalahan yang Sering Terjadi
Test meniru logika kode yang ditest. Ini yang paling sering. Kalau DiscountService menghitung $price - ($price * $discount / 100), test tidak boleh nulis hal yang sama untuk “membuktikan” hasilnya. Test cukup bilang: input 100 dan 10, output harus 90. Itu saja.
Semua ditest lewat Feature Test. Unit Test sangat cepat dan sangat terfokus. Kalau kamu malas bikin Unit Test dan langsung lompat ke Feature Test untuk semua hal, test suite kamu akan makin lambat dan sulit dilacak mana yang gagal kenapa.
External service tidak di-mock di Feature Test. Feature Test tidak seharusnya benar-benar mengirim email atau memanggil API berbayar. Selalu mock service eksternal di Feature Test — simpan Integration Test dengan service nyata untuk environment yang memang disiapkan untuk itu.
Tidak memakai RefreshDatabase. Kalau lupa pakai trait ini di Feature dan Integration Test, data dari satu test bisa mengotori test berikutnya dan hasilnya jadi tidak bisa dipercaya.
💡 Ingat!
Testing bukan soal menulis kode tambahan yang membuang waktu. Testing adalah percakapan dengan kode masa depan — versi kamu enam bulan lagi yang lupa kenapa logic ini ditulis seperti ini.
Kalau test hijau semua setelah refactor besar, kamu bisa tidur nyenyak. Kalau merah, kamu tahu persis apa yang pecah — bukan nunggu user yang laporan duluan.
Dan satu hal yang tidak bisa digantikan oleh test manapun: keputusan desain yang baik. Logic yang tertanam di Controller tidak bisa diunit-test. Service eksternal yang di-call langsung tanpa dibungkus tidak bisa di-mock dengan bersih. Testing yang mudah ditulis adalah sinyal bahwa arsitektur kodenya sudah baik — dan testing yang susah ditulis seringkali adalah sinyal sebaliknya.
Kalau kamu pernah bingung mau taruh logika di mana — di Observer, di Event Listener, atau langsung di Model — kamu tidak sendirian. Ketiganya bisa menjalankan hal yang “mirip”. Tapi masing-masing punya tempat yang tepat, dan salah pilih bisa bikin kode yang susah dilacak.
Artikel ini bahas tuntas perbedaannya, kapan pakai yang mana, dan bagaimana Event bisa diangkat satu level lebih jauh: dikirim ke browser secara real-time lewat WebSocket.
🤔 Dua Jenis “Reaksi” yang Berbeda
Sebelum masuk ke kode, ada satu kalimat yang perlu dipahami dulu:
Observer itu reaktif ke data. Event itu reaktif ke makna.
Artinya: Observer peduli pada perubahan data di model. Event peduli pada sesuatu yang terjadi di alur bisnis aplikasi.
Kedengarannya mirip, tapi implikasinya sangat berbeda. Mari mulai dari Observer.
👁️ Observer — “Pengamat Model”
Analoginya
Bayangin Observer seperti satpam gudang yang selalu berjaga.
Setiap kali ada barang yang masuk, dipindahkan, atau diambil dari gudang — satpam itu otomatis bereaksi. Dia tidak peduli kenapa barang itu bergerak, siapa yang memindahkan, atau apa konteks bisnisnya. Tugasnya satu: kalau model berubah, jalankan ini.
Observer di Laravel persis seperti itu — dia menempel ke lifecycle model (Eloquent) dan otomatis bereaksi setiap kali model melewati fase tertentu.
Aturan Observer
- Satu Observer = satu Model.
- Hanya bisa merespons hook lifecycle bawaan Eloquent:
creating,created,updating,updated,deleting,deleted,saving,saved,restoring,restored. - Tidak perlu di-trigger manual — dia otomatis jalan setiap kali model melewati event tersebut.
- Didaftarkan di
AppServiceProvider::boot().
Kodenya
php artisan make:observer UserObserver --model=User
📁 app/Observers/UserObserver.php
<?php
namespace App\Observers;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
class UserObserver
{
// Jalan SEBELUM user disimpan ke DB untuk pertama kali
public function creating(User $user): void
{
// Auto-generate UUID — ini urusan data, bukan bisnis
$user->uuid = Str::uuid();
}
// Jalan SETELAH user dihapus dari DB
public function deleted(User $user): void
{
// Bersihkan file yang tidak lagi dibutuhkan
Storage::delete($user->avatar);
}
}
Daftarkan di AppServiceProvider:
public function boot(): void
{
User::observe(UserObserver::class);
}
📌 Perhatikan — logika di dalam Observer tidak ada kaitannya dengan bisnis. Str::uuid() adalah urusan integritas data. Storage::delete() adalah urusan kebersihan data. Tidak ada logika “kirim email selamat datang” atau “notifikasi admin” di sini — itu bukan tugas Observer.
Kapan Pakai Observer
Pakai Observer untuk efek samping yang melekat ke model — hal-hal yang harus terjadi setiap kali model berubah, terlepas dari konteks bisnis apapun yang memicunya. Misalnya: auto-generate slug, auto-fill created_by, hapus file terkait saat model dihapus, atau invalidate cache saat data diupdate.
📢 Event & Listener — “Pengumuman dan Pendengar”
Analoginya
Bayangin Event seperti pengeras suara di kantor yang mengumumkan sesuatu.
“Perhatian: ada pelanggan baru yang baru saja mendaftar!”
Pengeras suara tidak peduli siapa yang mendengar, dan tidak peduli apa yang mereka lakukan setelah dengar. Tugasnya cuma mengumumkan.
Yang peduli adalah para pendengar — tim marketing langsung kirim email sambutan, tim data langsung catat ke analytics, tim sales langsung assign account manager. Masing-masing bereaksi sendiri-sendiri, tanpa saling tahu.
Event & Listener di Laravel persis seperti itu.
- Event = pengumuman bahwa sesuatu telah terjadi
- Listener = pihak yang bereaksi atas pengumuman itu — dan boleh ada banyak
Aturan Event & Listener
- Event adalah class biasa yang membawa data konteks kejadian.
- Satu Event bisa punya banyak Listener — masing-masing bereaksi sendiri tanpa saling ketergantungan.
- Listener bisa implements
ShouldQueue— artinya reaksinya bisa dijalankan di background. - Event di-trigger manual dengan
event(new NamaEvent(...)). - Tidak terikat lifecycle model — bisa ditrigger dari mana saja.
Kodenya
php artisan make:event UserRegistered
php artisan make:listener SendWelcomeEmail --event=UserRegistered
php artisan make:listener NotifyAdminSlack --event=UserRegistered
📁 app/Events/UserRegistered.php
Event adalah “amplop” yang membawa informasi tentang apa yang terjadi.
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class UserRegistered
{
use SerializesModels;
public function __construct(
public User $user
) {}
}
📁 app/Listeners/SendWelcomeEmail.php
Listener pertama — kirim email sambutan.
<?php
namespace App\Listeners;
use App\Events\UserRegistered;
use App\Mail\WelcomeMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
// ShouldQueue = reaksi ini dijalankan di background, tidak blokir request
class SendWelcomeEmail implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
Mail::to($event->user->email)->send(new WelcomeMail($event->user));
}
}
📁 app/Listeners/NotifyAdminSlack.php
Listener kedua — notifikasi ke Slack. Event yang sama, reaksi yang berbeda.
<?php
namespace App\Listeners;
use App\Events\UserRegistered;
use Illuminate\Contracts\Queue\ShouldQueue;
class NotifyAdminSlack implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
// Kirim notifikasi ke channel Slack admin
// Logika ini sepenuhnya terpisah dari listener lainnya
}
}
Daftarkan di EventServiceProvider (atau AppServiceProvider di Laravel 11+):
protected $listen = [
UserRegistered::class => [
SendWelcomeEmail::class,
NotifyAdminSlack::class,
],
];
Trigger dari Controller atau Service:
public function register(RegisterRequest $request)
{
$user = User::create($request->validated());
// Cukup umumkan — listener yang akan urus sisanya
event(new UserRegistered($user));
return response()->json(['message' => 'Registrasi berhasil'], 201);
}
📌 Perhatikan bagaimana Controller tidak tahu dan tidak peduli bahwa ada email yang dikirim dan ada Slack yang dinotifikasi. Controller cukup bilang “user sudah terdaftar”. Siapa yang bereaksi dan bagaimana caranya — itu urusan listener masing-masing.
Kapan Pakai Event & Listener
Pakai Event & Listener untuk logika bisnis yang punya makna — kejadian yang memang berarti sesuatu dalam konteks aplikasimu. Misalnya: OrderPlaced, PaymentFailed, UserPromotedToAdmin, SubscriptionExpired. Kejadian-kejadian ini bukan sekadar “model berubah” — mereka adalah momen penting yang mungkin perlu direspons oleh banyak bagian sistem.
🗺️ Gambaran Besar — Posisi Tiap Komponen
app/
├── Events/
│ ├── UserRegistered.php ← "Pengumuman" — apa yang terjadi
│ └── OrderPaid.php
│
├── Listeners/
│ ├── SendWelcomeEmail.php ← "Pendengar" — bereaksi atas pengumuman
│ └── NotifyAdminSlack.php
│
├── Observers/
│ └── UserObserver.php ← "Satpam" — reaktif ke perubahan model
Cara memilihnya:
Ada perubahan data di model? (creating, updated, deleted)
↓
→ Gunakan Observer
Ada kejadian bisnis yang bermakna? (user daftar, order dibayar)
↓
→ Gunakan Event & Listener
Logic itu harus menjadi bagian dari model itu sendiri?
↓
→ Gunakan Trait
📻 Broadcast Event — Bawa Real-Time ke Frontend
Sampai di sini, semua yang dibahas adalah komunikasi internal — antar komponen di dalam server. Event di-fire, Listener bereaksi, selesai.
Tapi ada skenario di mana reaksi tidak cukup di server. User yang sedang membuka browser perlu tahu seketika — tanpa refresh. Order masuk, notifikasi langsung muncul. Pembayaran berhasil, halaman langsung update.
Di sinilah Broadcast Event masuk.
Bagaimana Cara Kerjanya
Laravel tidak mengirim WebSocket secara langsung. Yang Laravel lakukan adalah mempublish event ke server WebSocket (Pusher, Soketi, atau Ably). Server WebSocket itu yang kemudian mendorong data ke semua client yang sedang terhubung.
User bayar → Controller → OrderPaid event di-fire
↓
Laravel publish ke broadcaster (Soketi / Pusher)
↓
Broadcaster push via WebSocket ke semua client
↓
Laravel Echo (JS) menerima → UI update tanpa reload
Empat Komponen Broadcast
| Komponen | Peran |
|---|---|
| Event | Apa yang terjadi di server |
| Broadcast Channel | Siapa yang boleh mendengar |
| Broadcaster | Server WebSocket (Pusher / Soketi / Ably) |
| Laravel Echo | Listener di sisi JavaScript / browser |
Implementasi Step by Step
Step 1 — Setup Broadcaster
Pilihan umum: Pusher (cloud, berbayar per koneksi) atau Soketi (self-host, gratis, kompatibel Pusher). Untuk development, Soketi adalah pilihan paling praktis.
# .env
BROADCAST_CONNECTION=pusher
PUSHER_APP_ID=local
PUSHER_APP_KEY=local
PUSHER_APP_SECRET=local
PUSHER_HOST=127.0.0.1
PUSHER_PORT=6001
PUSHER_SCHEME=http
Step 2 — Buat Event yang Bisa Di-Broadcast
php artisan make:event OrderPaid
📁 app/Events/OrderPaid.php
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
class OrderPaid implements ShouldBroadcast
{
use SerializesModels;
public function __construct(
public Order $order
) {}
// Tentukan di channel mana event ini di-broadcast
public function broadcastOn(): Channel
{
// Private channel — hanya pemilik order yang boleh dengar
return new PrivateChannel('orders.' . $this->order->user_id);
}
}
📌 implements ShouldBroadcast — ini yang membedakan event biasa dari broadcast event. Tanpa interface ini, event hanya berjalan internal di server dan tidak akan pernah sampai ke browser.
Step 3 — Otorisasi Channel
📁 routes/channels.php
use Illuminate\Support\Facades\Broadcast;
// Public channel — siapapun boleh dengar
Broadcast::channel('notifications', function ($user) {
return true;
});
// Private channel — hanya user yang punya order ini
Broadcast::channel('orders.{userId}', function ($user, $userId) {
return $user->id === (int) $userId;
});
Step 4 — Trigger Event
// Dari Controller, Service, atau Job — sama saja
public function pay(Order $order)
{
$order->update(['status' => 'paid']);
// Fire event — Laravel otomatis broadcast ke Soketi
event(new OrderPaid($order));
return response()->json(['message' => 'Pembayaran berhasil']);
}
Step 5 — Setup Laravel Echo di Frontend
npm install laravel-echo pusher-js
📁 resources/js/bootstrap.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: 'local',
wsHost: window.location.hostname,
wsPort: 6001,
forceTLS: false,
disableStats: true,
});
Step 6 — Listen di Frontend
// Di komponen Vue, React, atau script biasa
const userId = {{ auth()->id() }};
Echo.private('orders.' + userId)
.listen('OrderPaid', (event) => {
console.log('Order dibayar:', event.order);
// Update UI tanpa reload
showNotification('Pembayaran berhasil! Order #' + event.order.id);
updateOrderStatus(event.order.id, 'paid');
});
📌 Nama event yang dipakai di .listen() adalah nama class PHP-nya (OrderPaid), bukan string manual. Laravel otomatis mengonversinya.
📡 Public vs Private vs Presence Channel
Tidak semua data boleh diterima siapa saja. Laravel punya tiga jenis channel dengan tingkat akses berbeda.
Public Channel — tidak butuh autentikasi. Cocok untuk data yang memang publik: harga saham live, jumlah pengunjung online, notifikasi global.
return new Channel('announcements');
Echo.channel('announcements').listen('NewAnnouncement', (e) => { ... });
Private Channel — hanya user yang sudah login dan lolos otorisasi yang bisa masuk. Cocok untuk notifikasi personal, update order milik sendiri, atau chat privat.
return new PrivateChannel('orders.' . $this->order->user_id);
Echo.private('orders.' + userId).listen('OrderPaid', (e) => { ... });
Presence Channel — seperti Private Channel, tapi semua member bisa saling tahu siapa saja yang sedang terhubung. Cocok untuk fitur “siapa yang sedang online”, indikator “sedang mengetik”, atau collaborative editing.
return new PresenceChannel('room.' . $this->roomId);
Echo.join('room.' + roomId)
.here((users) => { /* daftar user yang online */ })
.joining((user) => { /* user baru masuk */ })
.leaving((user) => { /* user keluar */ });
⚠️ Kesalahan yang Sering Terjadi
Observer untuk logika bisnis. Observer bereaksi ke perubahan data, bukan ke kejadian bisnis. Kalau kamu taruh “kirim email selamat datang” di Observer created, semua kode yang membuat user — termasuk seeder, factory testing, atau import data — juga akan trigger email itu. Bukan yang kamu mau.
Heavy logic di dalam Event class. Event adalah sinyal, bukan processor. Event class cukup membawa data konteks. Logikanya ada di Listener, bukan di Event.
Pakai Observer untuk real-time. Observer tidak bisa di-broadcast. Kalau kamu butuh perubahan model dikirim ke browser secara real-time, fire Event dari Observer — lalu broadcast Event itu.
Lupa ShouldBroadcast. Event tanpa implements ShouldBroadcast hanya berjalan internal. Tidak ada yang sampai ke browser, tidak ada error — hanya diam.
📊 Perbandingan Singkat
| Observer | Event & Listener | Broadcast Event | |
|---|---|---|---|
| Trigger | Otomatis (lifecycle model) | Manual (event(...)) | Manual (event(...)) |
| Terikat model | ✅ Ya | ❌ Tidak harus | ❌ Tidak harus |
| Bisa banyak reaksi | ❌ Satu Observer | ✅ Banyak Listener | ✅ Banyak Listener |
| Bisa queue (async) | ❌ | ✅ ShouldQueue | ✅ ShouldQueue |
| Sampai ke browser | ❌ | ❌ | ✅ Via WebSocket |
| Cocok untuk | Data hygiene | Business flow | Real-time UI |
💡 Ingat!
Satu kalimat yang merangkum semuanya:
- Observer → data berubah, ini yang harus beres
- Event Listener → sesuatu terjadi, ini yang harus dilakukan
- Broadcast Event → sesuatu terjadi, browser perlu tahu sekarang
Dan satu prinsip yang paling sering dilupakan: Event adalah sinyal, bukan UI concern — tapi UI boleh mendengar sinyal itu.
Artinya, kamu tidak desain Event untuk memenuhi kebutuhan frontend. Kamu desain Event berdasarkan logika bisnis. Frontend yang kemudian “menempelkan diri” ke Event itu lewat Echo — bukan sebaliknya.
Pernah kamu klik tombol “Kirim Invoice” dan layarnya loading lama banget? Mungkin sampai 5-10 detik? Itu tanda ada pekerjaan berat yang sedang dikerjakan langsung di request yang sama — generate PDF, kirim email, mungkin sekaligus update beberapa tabel.
User menunggu. Dan selama user menunggu, dia tidak bisa ngapa-ngapain.
Laravel Queue hadir untuk memecah situasi itu. Bukan dengan membuat prosesnya jadi lebih cepat — tapi dengan memindahkan pekerjaan beratnya ke belakang layar, supaya user bisa langsung lanjut.
🤔 Masalah yang Diselesaikan
Tanpa Queue, alurnya terlihat seperti ini:
Request → Controller → Kirim email → Generate PDF → Response
↑
User menunggu di sini
Dengan Queue, alurnya jadi:
Request → Controller → Dispatch Job → Response ✅ (langsung)
↓
Worker (background)
↓
Kirim email, generate PDF — di sini, tanpa ganggu user
User dapat response cepat. Pekerjaan beratnya tetap berjalan — hanya saja sekarang di proses terpisah, di waktu yang berbeda, tanpa menghalangi siapapun.
🏗️ Tiga Komponen Utama
Sebelum masuk ke alur kerja, penting untuk tahu tiga komponen yang bekerja bersama di sistem Queue.
Job adalah unit pekerjaan yang ingin ditunda. Satu Job = satu tugas spesifik. Misalnya SendInvoiceEmail atau GenerateMonthlyReport. Job bukan tempat untuk logika bisnis yang kompleks — dia cukup tahu satu hal: apa yang harus dikerjakan.
Queue Driver adalah tempat antrian disimpan. Bisa database, Redis, atau Amazon SQS. Driver ini yang “memegang” job selama menunggu dieksekusi.
Queue Worker adalah proses yang berjalan di background, terus-menerus mengambil job dari driver dan menjalankannya. Tanpa worker yang aktif, job yang sudah di-dispatch tidak akan pernah dijalankan.
🪜 Alur Kerja dari Awal Sampai Akhir
1. Buat Job-nya
php artisan make:job SendInvoiceEmail
📁 app/Jobs/SendInvoiceEmail.php
<?php
namespace App\Jobs;
use App\Models\Order;
use App\Mail\InvoiceMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
class SendInvoiceEmail implements ShouldQueue
{
use Queueable;
// Simpan ID, bukan object — penjelasannya ada di bawah
public function __construct(
public int $orderId
) {}
public function handle(): void
{
// Fetch ulang di sini, bukan di constructor
$order = Order::find($this->orderId);
Mail::to($order->user->email)->send(new InvoiceMail($order));
}
}
📌 Perhatikan implements ShouldQueue — ini yang memberitahu Laravel bahwa class ini harus masuk antrian, bukan langsung dijalankan. Tanpa interface ini, job langsung dieksekusi saat dispatch, mengalahkan tujuan Queue itu sendiri.
2. Dispatch dari Controller
public function store(Request $request)
{
$order = Order::create($request->validated());
// Job didaftarkan ke antrian — TIDAK dijalankan di sini
SendInvoiceEmail::dispatch($order->id);
// Response langsung dikirim ke user
return response()->json(['message' => 'Order berhasil dibuat'], 201);
}
Saat dispatch() dipanggil, yang terjadi adalah:
- Job diserialisasi menjadi JSON
- JSON itu disimpan ke driver (database / Redis / SQS)
- Response langsung dikirim ke user — tidak menunggu email terkirim
3. Worker Mengambil dan Menjalankannya
php artisan queue:work
Worker berjalan dalam loop:
ambil job dari driver
↓
jalankan handle()
↓
sukses? → hapus job dari antrian
gagal? → retry atau pindah ke failed_jobs
↓
ambil job berikutnya...
⚙️ Fitur-Fitur Queue yang Wajib Tahu
Retry Otomatis — Kalau Job Gagal
Job bisa gagal. Koneksi email putus, API eksternal timeout, database sedang bermasalah. Queue punya mekanisme retry bawaan.
class SendInvoiceEmail implements ShouldQueue
{
// Maksimal 3 percobaan sebelum dianggap gagal permanen
public int $tries = 3;
// Tunggu 10 detik sebelum retry pertama, 20 detik sebelum kedua, dst.
public int $backoff = 10;
}
Atau langsung di command worker:
php artisan queue:work --tries=3
Kalau sudah 3 kali tetap gagal, job masuk ke tabel failed_jobs. Dari sana masih bisa dijalankan ulang secara manual:
php artisan queue:retry all
Delay — Jalankan Nanti, Bukan Sekarang
// Kirim email onboarding 24 jam setelah user daftar
SendOnboardingEmail::dispatch($user->id)
->delay(now()->addHours(24));
Job tidak langsung dieksekusi begitu di-dispatch — dia menunggu di antrian sampai waktunya tiba.
Priority Queue — Mana yang Didahulukan
Tidak semua job setara urgensinya. Reset password jelas lebih urgent dari laporan bulanan.
// Dispatch ke antrian berbeda berdasarkan urgensi
SendPasswordReset::dispatch($user->id)->onQueue('high');
GenerateMonthlyReport::dispatch()->onQueue('low');
Worker dikonfigurasi untuk mendahulukan antrian high:
php artisan queue:work --queue=high,default,low
Worker akan menguras semua job di high dulu sebelum menyentuh default, baru terakhir low.
⚠️ Jebakan Serialisasi — Yang Sering Bikin Bug
Ini salah satu hal paling penting yang sering diabaikan pemula.
Job bisa dijalankan di proses yang berbeda, bahkan di server yang berbeda. Artinya, object yang hidup di memory saat dispatch() tidak bisa dibawa ke sana. Object harus diserialisasi dulu (diubah jadi JSON), disimpan ke driver, lalu di-deserialisasi saat worker menjalankannya.
// ❌ SALAH — membawa object Auth::user() yang hidup di memory
public function __construct()
{
$this->user = Auth::user(); // object ini tidak bisa di-serialize dengan aman
}
// ✅ BENAR — simpan ID saja, fetch ulang di handle()
public function __construct(
public int $userId
) {}
public function handle(): void
{
$user = User::find($this->userId); // fetch fresh dari DB
}
📌 Kenapa ini penting? Karena data user di database saat job dijalankan bisa saja sudah berbeda dari saat job di-dispatch. Fetch ulang di handle() memastikan job selalu bekerja dengan data yang paling baru.
🛡️ Job Idempotent — Aman Dijalankan Berkali-kali
Queue punya fitur auto-retry. Artinya dalam kondisi tertentu, satu job bisa dijalankan lebih dari sekali — dan itu harus tetap menghasilkan hasil yang benar.
// ❌ Job non-idempotent — berbahaya kalau diretry
public function handle(): void
{
Stock::decrement('qty', 1); // kalau dijalankan 3x, qty berkurang 3
}
// ✅ Job idempotent — aman diretry berapa kalipun
public function handle(): void
{
// Cek dulu apakah job ini sudah pernah diproses
if (ProcessedJob::where('job_id', $this->job->getJobId())->exists()) {
return; // sudah diproses, tidak perlu ulang
}
Stock::decrement('qty', 1);
// Tandai sudah diproses
ProcessedJob::create(['job_id' => $this->job->getJobId()]);
}
Prinsipnya sederhana: dijalankan 1 kali atau 100 kali, hasilnya harus tetap sama. Ini yang disebut idempotent — dan semua job yang punya retry wajib didesain seperti ini.
Anti-pattern yang harus dihindari: “matikan saja retry-nya”, atau “kasih tau user jangan klik dua kali”. Itu bukan solusi — itu hanya berharap network selalu stabil dan user selalu sempurna. Program yang harus idempotent, bukan behavior yang tidak bisa kita kendalikan.
🚗 Driver Queue — Mana yang Harus Dipilih?
Driver adalah “gudang” tempat job disimpan sambil menunggu dieksekusi. Laravel punya beberapa pilihan dengan karakteristik yang sangat berbeda.
Database — Paling Simpel, Paling Lambat
Cara kerjanya: job disimpan di tabel jobs di database yang sama dengan aplikasi. Worker terus-menerus polling tabel itu untuk mencari job baru.
QUEUE_CONNECTION=database
# Buat tabel jobs dan failed_jobs
php artisan queue:table
php artisan migrate
Kelebihannya jelas — tidak perlu service tambahan. Langsung pakai database yang sudah ada. Setup lima menit, langsung jalan.
Kekurangannya: worker terus-menerus query database meskipun tidak ada job baru. Di traffic tinggi, ini menambah beban DB yang signifikan. Latensi-nya juga lebih tinggi dibanding Redis karena membaca dari disk, bukan dari memory.
Cocok untuk: development, staging, aplikasi kecil dengan traffic rendah, atau kalau budget tidak memungkinkan infrastruktur tambahan.
Redis — Cepat, Efisien, Standar Production
Cara kerjanya: job disimpan di memory Redis. Worker menggunakan teknik blocking pop — kalau antrian kosong, worker diam menunggu. Begitu ada job masuk, langsung diambil. Hampir real-time.
QUEUE_CONNECTION=redis
Kelebihannya: sangat cepat, efisien, dan sudah jadi standar de facto untuk production Laravel. Mayoritas aplikasi Laravel skala menengah ke atas pakai Redis untuk queue — apalagi kalau sudah pakai Redis untuk caching, tidak perlu tambah service baru.
Kekurangannya: butuh Redis server. Dan karena Redis menyimpan data di RAM, ada risiko data hilang kalau Redis crash — kecuali persistence diaktifkan.
# redis.conf — aktifkan AOF untuk persistence
appendonly yes
appendfsync everysec
📌 Salah kaprah yang sering muncul: “Redis selalu lebih aman karena lebih cepat.” Tidak tepat. Redis cepat, tapi data-nya volatile by default. AOF (Append Only File) atau RDB (Redis Database Snapshot) yang membuat Redis aman untuk production.
Cocok untuk: production, job dengan volume tinggi, aplikasi yang butuh respons cepat, atau sudah punya Redis untuk caching.
Amazon SQS — Managed, Skalabel, Tidak Perlu Urus Server
Cara kerjanya: job disimpan di layanan antrian milik AWS. Worker menarik job via API. Semuanya dikelola AWS — scaling, availability, redundancy.
QUEUE_CONNECTION=sqs
Kelebihannya: auto-scaling tanpa konfigurasi manual, highly available, dan tidak perlu memikirkan server antrian sama sekali. Cocok untuk arsitektur multi-service atau microservice yang sudah berjalan di ekosistem AWS.
Kekurangannya: latensi lebih tinggi dari Redis karena setiap operasi adalah HTTP request ke AWS. Ada biaya per request. Dan bergantung pada ketersediaan AWS di region yang dipakai.
📌 Salah kaprah yang sering muncul: “SQS paling cepat karena cloud.” Tidak. Redis lebih cepat. SQS lebih reliable dan scalable tanpa effort — itu nilai jualnya, bukan kecepatan raw-nya.
Cocok untuk: arsitektur cloud-native di AWS, sistem dengan traffic yang sangat tidak stabil (spike besar tiba-tiba), atau kalau tim tidak mau repot urus infra antrian sendiri.
Sync — Untuk Testing, Bukan Production
QUEUE_CONNECTION=sync
Dengan driver sync, job langsung dijalankan saat di-dispatch — tidak masuk antrian, tidak butuh worker. Ini bukan untuk production. Ini khusus untuk testing dan development lokal supaya tidak perlu menjalankan worker terpisah.
📊 Perbandingan Driver
| Database | Redis | SQS | |
|---|---|---|---|
| Setup | Sangat mudah | Perlu Redis server | Perlu akun AWS |
| Kecepatan | Lambat | Paling cepat | Sedang |
| Reliability | Sedang | Butuh config AOF/RDB | Paling reliable |
| Scalability | Terbatas | Baik | Auto-scale |
| Biaya | Gratis | Server Redis | Per request |
| Cocok untuk | Dev / kecil | Production umum | Cloud-native |
💡 Prinsipnya: Pilih driver berdasarkan bottleneck aplikasimu, bukan tren. Aplikasi kecil tidak butuh Redis. Aplikasi besar di AWS tidak harus SQS kalau Redis sudah mencukupi.
🧭 Kapan HARUS Pakai Queue, Kapan Tidak?
Pakai Queue untuk:
- Kirim email, notifikasi push, atau SMS
- Generate laporan, PDF, atau file ekspor
- Sinkronisasi ke API eksternal (payment gateway, ERP, dsb.)
- Upload dan proses file (resize gambar, transcode video)
- Audit logging yang berat
- Pekerjaan apapun yang butuh lebih dari ~500ms
Jangan pakai Queue untuk:
- Logic yang hasilnya harus langsung dipakai di response — misalnya cek stok sebelum konfirmasi order
- Validasi input — user harus tahu hasilnya seketika
- Transaksi database inti yang tidak boleh ditunda atau gagal diam-diam
- Authorization — kalau ditolak, user harus tahu sekarang
💡 Ingat!
Ada satu hal yang sering disalahpahami: Queue bukan async penuh.
Di level request, ya — user dapat response langsung tanpa menunggu job selesai. Tapi di level worker, job tetap dijalankan satu per satu secara synchronous. Satu worker, satu job dalam satu waktu.
Kalau volume job tinggi dan satu worker tidak cukup, solusinya bukan ganti driver — tapi tambah jumlah worker. Di production, worker biasanya dikelola oleh process manager seperti Supervisor, supaya worker otomatis restart kalau mati dan jumlahnya bisa dikonfigurasi sesuai kebutuhan traffic.
Queue memindahkan waktu eksekusi, bukan memindahkan tanggung jawab bisnis. Pekerjaan tetap harus selesai — hanya saja sekarang tidak harus di sini, dan tidak harus sekarang.
Kalau kamu pernah bikin API Laravel dan langsung return User::find(1) dari controller, kamu tidak sendirian. Sekilas memang simpel dan kerjaannya beres. Tapi ada satu masalah yang tidak langsung kelihatan — kamu baru sadar pas kena sendiri.
Artikel ini bahas dua hal yang saling berkaitan: bagaimana kamu membentuk response API, dan bagaimana kamu mengelola perubahan API tanpa merusak client yang sudah ada.
🤔 Masalah yang Muncul Kalau Dibiarkan
Bayangin kamu punya endpoint /api/users/{id} yang langsung return model User.
Awalnya enak. Tidak perlu class tambahan, data langsung keluar. Tapi seiring waktu:
- Password hash ikut ter-return karena lupa sembunyikan
- Frontend tiba-tiba error karena kamu rename kolom
namejadifull_namedi database — dan response otomatis ikut berubah - Mobile app di Play Store yang belum di-update mendadak crash
- Tim frontend beda kota mengeluh karena struktur response tiba-tiba tidak konsisten
Semua masalah itu bukan soal kode yang salah — tapi soal tidak ada lapisan antara internal model dan response publik.
API Resource hadir untuk jadi lapisan itu. Versioning hadir untuk memastikan perubahan tidak langsung membunuh client lama.
🎭 API Resource — “Juru Bicara Model”
Analoginya
Bayangin sebuah perusahaan besar yang punya juru bicara resmi.
Jurnalis tidak bisa langsung masuk ke kantor dan nanya ke sembarang karyawan. Semua informasi keluar lewat satu orang — juru bicara. Dia yang menentukan apa yang boleh disampaikan ke publik, dalam format apa, dan dengan kata-kata seperti apa.
Kalau ada informasi internal yang sensitif? Juru bicara tidak mengucapkannya. Kalau ada perubahan kebijakan? Juru bicara yang memformulasikannya ulang sebelum keluar.
API Resource di Laravel persis seperti itu. Dia duduk di antara model dan response — memutuskan apa yang boleh keluar dan bagaimana bentuknya.
Tanpa API Resource — Apa Yang Terjadi?
// Controller langsung return model — ini yang jangan dilakukan
public function show(User $user)
{
return User::find($user->id);
}
Output yang keluar:
{
"id": 1,
"name": "Budi",
"email": "budi@mail.com",
"password": "$2y$10$abc...",
"remember_token": "xyz...",
"email_verified_at": "2024-01-01T00:00:00.000000Z",
"created_at": "2024-01-01T00:00:00.000000Z",
"updated_at": "2024-01-05T00:00:00.000000Z"
}
password dan remember_token ikut keluar. Kalau besok kolom name di database diganti jadi full_name, response langsung berubah — dan client yang bergantung pada field name langsung error.
Membuat API Resource
php artisan make:resource UserResource
📁 app/Http/Resources/UserResource.php
Disimpan di folder
Resources. Satu Resource = satu “versi presentasi” dari model.
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
// Hanya field yang memang boleh keluar ke publik
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// Boleh rename key — kalau DB berubah, cukup ubah di sini
'joined_at' => $this->created_at->toDateString(),
];
}
}
📌 Perhatikan — password, remember_token, semua field internal tidak ada di sini. Itu bukan kebetulan. Itu keputusan sadar. Dan kalau besok kolom name di DB diganti jadi full_name, kamu cukup ubah satu baris: 'name' => $this->full_name. Client tidak perlu tahu apa-apa.
Cara Memakainya di Controller
// Satu objek
public function show(User $user)
{
return new UserResource($user);
}
// Koleksi
public function index()
{
return UserResource::collection(User::all());
}
// Dengan pagination — links dan meta otomatis ikut
public function index()
{
return UserResource::collection(User::paginate(15));
}
⚡ Fitur-Fitur Resource yang Wajib Tahu
Conditional Attribute — Output Berbeda per Role
Kadang ada field yang boleh dilihat admin tapi tidak boleh dilihat user biasa. Ini bisa langsung ditangani di Resource.
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// Hanya keluar kalau yang request adalah admin
'phone' => $this->when($request->user()?->isAdmin(), $this->phone),
'ip_address' => $this->when($request->user()?->isAdmin(), $this->last_ip),
];
}
📌 Kalau kondisinya tidak terpenuhi, key-nya tidak ikut masuk ke response sama sekali — bukan null, tapi benar-benar tidak ada.
Relationship Loading — Hindari N+1 dengan Elegan
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// whenLoaded memastikan posts hanya ikut jika sudah di-load
// tidak akan trigger query tambahan kalau belum di-eager load
'posts' => PostResource::collection($this->whenLoaded('posts')),
];
}
Di controller, kamu yang menentukan kapan relasi ikut di-load:
// Tanpa posts
return new UserResource(User::find($id));
// Dengan posts — di-eager load dulu
return new UserResource(User::with('posts')->find($id));
🗂️ Gambaran Besar — Posisi Resource di Arsitektur
Request
↓
Controller
↓
Service / Model (logika & data)
↓
API Resource ← "Juru bicara" — di sinilah transformasi terjadi
↓
Response (JSON)
Controller boleh berubah. Service boleh refactor. Model boleh rename kolom. Selama Resource kamu jaga konsisten, client di luar tidak merasakannya.
💡 Prinsipnya: Controller boleh berubah, Service boleh berubah, Model boleh berubah — Resource tidak boleh sembarang berubah. Karena Resource adalah kontrak yang sudah kamu pegang ke client.
📡 API Versioning — “Kontrak yang Berevolusi”
Analoginya
Bayangin kamu punya franchise restoran yang sudah tersebar di 50 kota. Suatu hari kamu memutuskan untuk mengganti resep andalan — rasa dan nama menunya berubah.
Kalau kamu langsung ganti semua resto serentak, pelanggan setia yang sudah hafal menu lama akan kaget. Mungkin ada yang batal datang karena menu favorit mereka “hilang”.
Cara yang benar: buka menu edisi baru (V2) di sebelah menu lama (V1). Kasih waktu ke pelanggan untuk beralih. Baru setelah mereka sudah terbiasa, menu lama ditutup resmi.
API Versioning persis seperti itu. Kamu tidak paksa semua client upgrade serentak — kamu beri mereka jalan keluar yang terencana.
Kenapa Versioning Itu Wajib
Lihat skenario kegagalan ini:
// Response v1 yang sudah dipakai client
{ "name": "Rico Ardiansyah" }
// Setelah "update" tanpa versioning
{ "full_name": "Rico Ardiansyah" }
Satu rename field. Mobile app di Play Store yang belum update langsung crash karena name tidak ditemukan. Ratusan user terdampak. Dan kamu tidak bisa rollback begitu saja karena sisi server sudah terlanjur berubah.
Versioning melindungi client, bukan server.
Struktur Folder yang Disarankan
app/
├── Http/
│ ├── Controllers/
│ │ └── Api/
│ │ ├── V1/
│ │ │ └── UserController.php
│ │ └── V2/
│ │ └── UserController.php
│ └── Resources/
│ └── Api/
│ ├── V1/
│ │ └── UserResource.php
│ └── V2/
│ └── UserResource.php
routes/
└── api.php
📌 Yang perlu di-versioning hanya Controller dan Resource — karena keduanya bersentuhan langsung dengan Request dan Response. Model, Service, Repository? Tidak perlu ikut di-versioning. Mereka adalah logika internal yang tidak perlu klien tahu.
Routing dengan Versioning
📁 routes/api.php
use Illuminate\Support\Facades\Route;
// V1 — masih aktif, tapi akan segera deprecated
Route::prefix('v1')->group(function () {
Route::get('/users/{user}', [\App\Http\Controllers\Api\V1\UserController::class, 'show']);
Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
});
// V2 — versi baru dengan struktur response yang berbeda
Route::prefix('v2')->group(function () {
Route::get('/users/{user}', [\App\Http\Controllers\Api\V2\UserController::class, 'show']);
Route::apiResource('posts', \App\Http\Controllers\Api\V2\PostController::class);
});
Dengan begini, client lama yang masih pakai /api/v1/users/1 tetap berjalan. Client baru bisa langsung pakai /api/v2/users/1. Keduanya hidup berdampingan.
🚨 Memberi Sinyal Deprecation ke Client
Setelah V2 siap, V1 tidak langsung dimatikan. Ada proses komunikasi dulu. Standar industrinya pakai HTTP response header — ini bukan aturan Laravel, tapi standar RFC yang dipakai oleh API-API besar.
Deprecation: true
Sunset: 2026-06-01
Artinya: “Endpoint ini masih berjalan, tapi resmi akan dimatikan tanggal 1 Juni 2026. Segera migrasi.”
Client yang baik akan membaca header ini, log peringatannya, dan jadwalkan migrasi sebelum tanggal Sunset tiba.
Cara Paling Elegan — Middleware Khusus V1
php artisan make:middleware ApiV1Deprecation
📁 app/Http/Middleware/ApiV1Deprecation.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ApiV1Deprecation
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Tambahkan header deprecation ke semua response V1
// tanpa mengubah body response sama sekali
return $response
->header('Deprecation', 'true')
->header('Sunset', '2026-06-01');
}
}
Daftarkan alias di bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'api.v1.deprecated' => \App\Http\Middleware\ApiV1Deprecation::class,
]);
})
Pasang ke semua route V1:
Route::prefix('v1')
->middleware(['api', 'api.v1.deprecated'])
->group(function () {
Route::get('/users/{user}', [\App\Http\Controllers\Api\V1\UserController::class, 'show']);
});
Sekarang setiap response dari endpoint V1 otomatis membawa header ini:
HTTP/1.1 200 OK
Deprecation: true
Sunset: 2026-06-01
Content-Type: application/json
Kalau tanggal Sunset sudah lewat, shutdown V1 dengan bersih:
Route::prefix('v1')->group(function () {
abort(410, 'API version v1 has been sunset. Please migrate to v2.');
});
📌 410 Gone — bukan 404 Not Found. 410 artinya resource ini memang sengaja dihapus secara permanen, bukan tidak ditemukan. Ini sinyal yang jelas ke client bahwa ini bukan bug, tapi keputusan.
🗺️ Kapan Harus Bikin Versi Baru?
Tidak semua perubahan perlu versi baru. Yang perlu:
- Rename field —
name→full_name - Hapus field dari response
- Ubah tipe data — string jadi object, integer jadi string
- Ubah behavior endpoint — yang dulu return semua, sekarang di-filter
- Ubah struktur request — parameter yang dulu opsional jadi wajib
- Ubah format response — yang dulu flat jadi nested
Yang tidak perlu versi baru:
- Tambah field baru ke response (client lama akan ignore field yang tidak mereka kenal)
- Fix bug yang memang responsenya salah
- Perubahan internal yang tidak kelihatan dari luar
📊 Perbandingan Singkat
| Tanpa Resource | Dengan Resource | |
|---|---|---|
| Field sensitif bocor | ✅ Bisa terjadi | ❌ Terkontrol |
| Perubahan DB langsung mengubah response | ✅ Otomatis | ❌ Harus ubah manual |
| Conditional output per role | ❌ Ribet di controller | ✅ Built-in when() |
| Relationship loading aman | ❌ Rawan N+1 | ✅ whenLoaded() |
| Konsistensi response | ❌ Tergantung controller | ✅ Terpusat |
💡 Ingat!
Dua hal ini — Resource dan Versioning — sering dianggap optional dan baru dikerjakan kalau sudah kepepet. Padahal itu cara paling cepat untuk membuat API yang susah dirawat.
Resource itu bukan boilerplate yang memperumit. Dia adalah kontrak tertulis antara server dan client. Versioning itu bukan birokrasi yang memperlambat. Dia adalah rasa hormat ke semua orang yang sudah bergantung pada API-mu.
Kalau API-mu hanya dipakai internal dan satu tim, mungkin tidak terasa urgent. Tapi begitu ada mobile app, frontend beda tim, atau client pihak ketiga — kamu akan sangat bersyukur fondasi ini sudah ada dari hari pertama.
Kalau kamu baru masuk ke dunia authorization di Laravel, dua hal ini pasti bikin bingung — bukan karena susah, tapi karena sekilas keliatannya sama. Keduanya soal “boleh atau tidak boleh”. Keduanya ada di ekosistem yang sama. Tapi ternyata mereka punya tempat yang berbeda.
Artikel ini mulai dari analogi dulu. Setelah ngerti kerangka berpikirnya, baru kita masuk ke kode.
🤔 Masalah yang Mereka Selesaikan
Bayangin kamu lagi bangun aplikasi blog. Di aplikasi ini ada dua skenario otorisasi yang berbeda:
- “Apakah user ini boleh masuk ke halaman dashboard admin?”
- “Apakah user ini boleh mengedit post ini?”
Sekilas keduanya tampak sama — dua-duanya soal izin. Tapi coba perhatikan lagi.
Pertanyaan pertama tidak peduli soal objek tertentu. Tidak ada “post yang mana” atau “user yang mana”. Cukup tanya satu hal: apakah kamu admin?
Pertanyaan kedua tidak bisa dijawab tanpa tahu post mana yang dimaksud. Boleh atau tidaknya tergantung pada apakah post itu miliknya sendiri.
Nah, inilah inti perbedaan Gate dan Policy.
Gate dirancang untuk pertanyaan pertama — aturan global yang tidak terikat objek tertentu.
Policy dirancang untuk pertanyaan kedua — aturan yang selalu melibatkan model spesifik.
🚦 Gate — “Peraturan Umum”
Analoginya
Bayangin Gate seperti papan peraturan di pintu masuk gedung kantor.
Di sana tertulis: “Hanya karyawan dengan ID badge merah yang boleh masuk ke lantai 10.”
Peraturan ini berlaku untuk siapapun yang mau masuk. Tidak peduli kamu mau ngapain di sana, tidak peduli kamu mau ketemu siapa. Satu aturan, berlaku di mana saja dan kapan saja.
Gate di Laravel persis seperti itu — dia mendefinisikan aturan izin yang sifatnya global, bukan terikat pada satu objek model tertentu.
Aturan Gate
- Didefinisikan pakai
Gate::define(), biasanya diAuthServiceProvider::boot(). - Isinya closure — fungsi yang menerima
$userdan mengembalikantrueataufalse. - Bisa dipanggil dari mana saja: controller, blade, route, service.
- Cocok untuk aturan yang tidak perlu tahu objek spesifiknya — cukup cek properti user.
Kodenya
📁 app/Providers/AuthServiceProvider.php
Semua
Gate::define()idealnya didaftarkan di sini, di dalam methodboot().
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Aturannya sederhana: hanya admin yang boleh
Gate::define('view-dashboard', fn ($user) => $user->is_admin);
// Bisa juga didefinisikan dengan closure panjang untuk logika lebih kompleks
Gate::define('manage-users', function ($user) {
return $user->is_admin && $user->is_active;
});
}
}
📌 Perhatikan — Gate::define() hanya menerima nama aturan (string) dan closure. Tidak ada model yang terlibat. Itu yang membuat Gate cocok untuk aturan global.
Cara Memakainya
✅ Di Controller (cara langsung):
use Illuminate\Support\Facades\Gate;
public function index()
{
if (! Gate::allows('view-dashboard')) {
abort(403);
}
return view('dashboard');
}
✅ Di Controller (cara rapi — otomatis throw 403):
public function index()
{
$this->authorize('view-dashboard');
return view('dashboard');
}
✅ Di Controller (kalau perlu handle sendiri):
public function index()
{
if (Gate::denies('view-dashboard')) {
return redirect('/')->with('error', 'Akses ditolak.');
}
return view('dashboard');
}
✅ Di Blade (buat sembunyikan elemen):
@can('view-dashboard')
<a href="/dashboard">Dashboard</a>
@endcan
✅ Di Route (paling ringkas):
Route::get('/dashboard', DashboardController::class)
->middleware('can:view-dashboard');
🗂️ Policy — “Aturan Per Model”
Analoginya
Sekarang bayangin Policy seperti peraturan kepemilikan barang di gudang.
Setiap barang punya label nama pemiliknya. Peraturannya jelas: “Hanya pemilik barang yang boleh mengambil atau memindahkannya.”
Tapi untuk menjalankan peraturan ini, kamu tidak bisa hanya tanya “kamu siapa?”. Kamu juga harus tahu “barang mana yang kamu mau ambil?”. Dua informasi itu selalu dibutuhkan bersamaan.
Policy di Laravel persis seperti itu — dia selalu bekerja dengan dua informasi: siapa user-nya dan objek model mana yang sedang diakses.
Aturan Policy
- Satu Policy = satu Model.
PostPolicyuntuk modelPost,CommentPolicyuntuk modelComment. - Dibuat sebagai class, bukan closure.
- Method-nya menerima
$userdan instance model sebagai parameter. - Laravel otomatis menemukannya (auto-discovery) atau bisa didaftarkan manual di
AuthServiceProvider.
Membuatnya
php artisan make:policy PostPolicy --model=Post
📁 app/Policies/PostPolicy.php
Disimpan di folder
Policies. Konvensi penamaan: nama model +Policy.
<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
// Boleh lihat semua post? (tidak butuh objek post spesifik)
public function viewAny(User $user): bool
{
return true; // semua user boleh
}
// Boleh lihat post ini?
public function view(User $user, Post $post): bool
{
return $post->is_published || $user->id === $post->user_id;
}
// Boleh buat post baru?
public function create(User $user): bool
{
return $user->is_verified;
}
// Boleh edit post ini? — Harus milik user sendiri
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
// Boleh hapus post ini?
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->is_admin;
}
}
📌 Perhatikan — setiap method yang butuh mengecek objek spesifik (view, update, delete) selalu menerima $post sebagai parameter kedua. Itulah yang membedakan Policy dari Gate: dia selalu tahu “ini post yang mana”.
Cara Memakainya
✅ Di Controller:
public function edit(Post $post)
{
// Laravel otomatis ambil PostPolicy dan cek method 'update'
$this->authorize('update', $post);
return view('posts.edit', compact('post'));
}
✅ Di Blade:
@can('update', $post)
<a href="/posts/{{ $post->id }}/edit">Edit</a>
@endcan
✅ Di Route:
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');
🗺️ Gambaran Besar — Posisi Tiap Komponen
Ini gambaran keseluruhan struktur dan hubungannya:
app/
├── Providers/
│ └── AuthServiceProvider.php ← Tempat daftar Gate
│
├── Policies/
│ ├── PostPolicy.php ← Policy untuk model Post
│ ├── CommentPolicy.php ← Policy untuk model Comment
│ └── UserPolicy.php ← Policy untuk model User
Cara kerja saat $this->authorize('update', $post) dipanggil:
Controller memanggil $this->authorize('update', $post)
↓
Laravel ambil user yang login
↓
Laravel deteksi: $post adalah instance Post → cari PostPolicy
↓
Jalankan PostPolicy::update($user, $post)
↓
true → lanjut | false → throw 403 AuthorizationException
🧭 Jadi, Kapan Pakai Yang Mana?
Pakai Gate ketika: Aturannya tidak butuh tahu objek spesifik — cukup cek properti user. Misalnya: “apakah user ini admin?”, “apakah user ini sudah verifikasi email?”, “apakah user ini punya plan premium?”
Pakai Policy ketika: Aturannya bergantung pada objek model tertentu yang sedang diakses. Misalnya: “apakah post ini milik user ini?”, “apakah komentar ini boleh dihapus user ini?”
Pakai keduanya (paling umum di Laravel) ketika: Gate untuk akses level tinggi (siapa yang boleh masuk ke fitur apa), Policy untuk akses level objek (siapa yang boleh melakukan apa ke objek tertentu).
📊 Perbandingan Singkat
| Gate | Policy | |
|---|---|---|
| Bentuknya | Closure | Class |
| Terikat model | ❌ | ✅ |
| Butuh objek spesifik | ❌ | ✅ |
| Cocok untuk | Aturan global | Aturan per-model |
| Tempat daftar | AuthServiceProvider | app/Policies/ |
| Contoh penggunaan | can:view-dashboard | can:update,post |
💡 Ingat!
Waktu pertama kali belajar ini, wajar kalau masih bingung dan ngerasa “kenapa tidak pakai Gate saja untuk semuanya?”
Boleh saja — Gate bisa menerima model sebagai parameter. Tapi begitu punya banyak aturan untuk satu model, semuanya akan menumpuk di AuthServiceProvider dan jadi berantakan.
Policy memisahkan aturan per model ke file terpisah. Ini bukan tentang benar atau salah — ini tentang kode yang mudah ditemukan dan dirawat saat aplikasi makin besar.
Analoginya: Gate itu papan pengumuman di lobi. Policy itu buku peraturan yang berbeda untuk tiap departemen. Keduanya perlu ada, dan keduanya punya peran masing-masing.
Kalau kamu baru mulai belajar authentication di Laravel, kamu pasti pernah bingung melihat daftar namanya: Breeze, Jetstream, Fortify, Sanctum, Passport. Semuanya berkaitan dengan auth, tapi tidak jelas mana yang harus dipakai dan apa bedanya.
Sebelum masuk ke kode, kita perlu ngerti dulu siapa yang melakukan apa — karena di sinilah kebingungan itu biasanya berasal.
🗺️ Peta Ekosistem Auth Laravel
Bayangkan auth di Laravel seperti restoran. Ada tiga lapisan yang berbeda tugasnya:
Breeze / Jetstream
↓
"Ruang makan + dekorasi" — UI yang dilihat user (form login, register, halaman profil)
Fortify
↓
"Dapur" — engine auth yang memproses semua logika (validasi, login, reset password)
Sanctum / Passport
↓
"Sistem kasir khusus" — manajemen token untuk API dan SPA
Breeze adalah starter kit yang paling simpel — dia install Fortify di balik layar dan sekaligus menyediakan halaman-halaman Blade (atau Inertia/Vue) yang sudah jadi.
Fortify adalah headless authentication — dia hanya menyediakan logika dan route, tanpa UI sama sekali. Cocok kalau kamu mau bikin tampilan sendiri atau butuh auth untuk SPA.
Sanctum dipakai untuk menerbitkan token API — baik personal access token untuk mobile app maupun cookie-based auth untuk SPA.
🔑 Bagian 1 — Alur Authentication Breeze (Web / Session)
Install
composer require laravel/breeze --dev
php artisan breeze:install blade # versi Blade biasa
# atau
php artisan breeze:install vue # versi Inertia + Vue
npm install && npm run dev
php artisan migrate
Setelah ini, Laravel menyediakan route, controller, dan view untuk: register, login, logout, forgot password, reset password, dan verifikasi email.
Alur Register
Ini yang terjadi ketika pengguna baru mengisi form registrasi:
1. User isi form → POST /register
2. RegisteredUserController@store dipanggil
3. Validasi input (nama, email, password)
└── Email harus unik, password minimal 8 karakter
4. User::create([...]) — password otomatis di-hash lewat bcrypt
5. Auth::login($user) — user langsung di-login setelah register
6. Session dibuat, session ID disimpan di cookie browser
7. Redirect ke /dashboard
Di balik layar, proses ini ditangani Fortify — Breeze hanya menyediakan tampilan form-nya.
Alur Login
1. User isi email + password → POST /login
2. Fortify::authenticateUsing dipanggil
└── Default: cek email + password dengan Auth::attempt()
3. Auth::attempt(['email' => ..., 'password' => ...])
└── Ambil user dari database berdasarkan email
└── Verifikasi password dengan Hash::check()
4. Kalau cocok → Auth::login($user)
└── Session dibuat
└── Session ID dikirim ke browser via cookie
5. Redirect ke /dashboard (atau intended URL)
Kalau gagal → redirect balik ke form login dengan error
Session — Apa yang Sebenarnya Terjadi
Ini bagian yang sering tidak dipahami secara utuh.
Browser Server (Laravel)
│ │
│ POST /login (email + password) │
│ ─────────────────────────────────► │
│ │ Validasi credentials
│ │ Buat session
│ │ Simpan user_id di session storage
│ │
│ Set-Cookie: laravel_session=xyz │
│ ◄───────────────────────────────── │
│ │
│ GET /dashboard │
│ Cookie: laravel_session=xyz │
│ ─────────────────────────────────► │
│ │ Baca cookie → cari session
│ │ Session ada → ambil user_id
│ │ Auth::user() tersedia
│ │
│ Response: halaman dashboard │
│ ◄───────────────────────────────── │
Yang disimpan di cookie browser bukan data user — hanya ID session. Data aslinya (termasuk user_id) tersimpan di server (file, database, atau Redis tergantung konfigurasi SESSION_DRIVER).
Middleware auth — Penjaga Route
Setelah user login, route yang butuh autentikasi dilindungi dengan middleware auth:
// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/dashboard', DashboardController::class);
Route::resource('/posts', PostController::class);
});
Yang terjadi ketika request masuk ke route ini:
Request → Middleware auth dipanggil
│
├── Auth::check() → apakah session punya user yang valid?
│
├── Ya → lanjutkan ke controller
│
└── Tidak → redirect ke /login
Mengakses user yang sedang login di controller:
// Cara 1 — lewat facade
$user = Auth::user();
$id = Auth::id();
// Cara 2 — lewat request (lebih disukai di controller)
$user = $request->user();
// Cara 3 — helper global
$user = auth()->user();
Alur Logout
// AuthenticatedSessionController@destroy
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
// Hapus data session saat ini
$request->session()->invalidate();
// Regenerate CSRF token untuk keamanan
$request->session()->regenerateToken();
return redirect('/');
}
Kustomisasi Logika Login
Fortify memungkinkan kamu mengganti logika autentikasi sepenuhnya lewat Fortify::authenticateUsing():
// app/Providers/FortifyServiceProvider.php
use Laravel\Fortify\Fortify;
use Illuminate\Support\Facades\Hash;
public function boot(): void
{
// Ganti logika default dengan logika kustom
Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first();
if ($user && Hash::check($request->password, $user->password)) {
// Tambahkan kondisi ekstra — misalnya cek status akun
if (! $user->is_active) {
return null; // tolak login
}
return $user;
}
return null; // credentials salah
});
}
📡 Bagian 2 — Authentication untuk API (Token-based)
Untuk API yang dikonsumsi mobile app atau SPA, session tidak relevan — karena HTTP stateless dan client tidak bisa menyimpan cookie dengan cara yang sama.
Solusinya adalah token: server menerbitkan token saat login, client menyimpannya, dan menyertakannya di setiap request berikutnya di header Authorization.
Install Sanctum
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Tambahkan trait HasApiTokens ke model User:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
Alur Login API
// routes/api.php
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', [AuthController::class, 'me']);
Route::post('/logout', [AuthController::class, 'logout']);
});
// app/Http/Controllers/AuthController.php
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
// Cek credentials
if (! Auth::attempt($request->only('email', 'password'))) {
return response()->json([
'message' => 'Email atau password salah'
], 401);
}
$user = Auth::user();
// Buat token — beri nama sesuai device/keperluan
$token = $user->createToken('mobile-app')->plainTextToken;
return response()->json([
'token' => $token,
'user' => $user,
]);
}
public function me(Request $request)
{
// Auth::user() tersedia karena middleware auth:sanctum
return response()->json($request->user());
}
public function logout(Request $request)
{
// Hapus hanya token yang sedang dipakai
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
Alur Request API dengan Token
Mobile App Laravel API
│ │
│ POST /api/login │
│ {email, password} │
│ ──────────────────────────────► │
│ │ Validasi → buat token
│ {token: "1|abc123..."} │
│ ◄────────────────────────────── │
│ │
│ Simpan token di storage lokal │
│ │
│ GET /api/me │
│ Authorization: Bearer 1|abc123 │
│ ──────────────────────────────► │
│ │ Middleware auth:sanctum
│ │ Cari token di tabel personal_access_tokens
│ │ Token valid → ambil user
│ │
│ {id: 1, name: "Budi", ...} │
│ ◄────────────────────────────── │
🔄 Bagian 3 — Session Auth vs Token Auth
Ini perbandingan yang perlu dipahami sebelum memilih pendekatan:
Session Auth (Breeze / web) Token Auth (Sanctum / API)
──────────────────────────── ──────────────────────────
State tersimpan di server Stateless — server tidak
(file / database / redis) simpan state apapun
Identifikasi via cookie Identifikasi via header
laravel_session=xyz Authorization: Bearer xyz
CSRF protection dibutuhkan CSRF tidak relevan
(karena cookie otomatis terkirim) (token harus eksplisit dikirim)
Cocok untuk: Cocok untuk:
- Web app tradisional - REST API
- Server-side rendered (Blade) - Mobile app
- SPA same-domain - SPA cross-domain
🗺️ Gambaran Besar — Semua Komponen dan Posisinya
Permintaan login dari user
│
▼
┌─────────────────────────────────┐
│ routes/web.php │
│ POST /login │
│ Middleware: guest │ ← hanya boleh diakses kalau belum login
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ Fortify (engine di balik layar)│
│ authenticateUsing() │
│ → Auth::attempt() │
│ → Hash::check() │
└──────────────┬──────────────────┘
│
┌───────┴───────┐
│ Berhasil │ Gagal
▼ ▼
Auth::login() redirect + error
Session dibuat
│
▼
┌─────────────────────────────────┐
│ Route berikutnya │
│ Middleware: auth │ ← cek session / token
│ Auth::user() tersedia │
│ Controller berjalan │
└─────────────────────────────────┘
📊 Perbandingan Breeze vs Fortify vs Sanctum
| Breeze | Fortify | Sanctum | |
|---|---|---|---|
| Menyediakan UI | ✅ Blade / Inertia | ❌ | ❌ |
| Logika auth | Lewat Fortify | ✅ Langsung | — |
| Untuk web (session) | ✅ | ✅ | ✅ (SPA) |
| Untuk API (token) | ❌ | ❌ | ✅ |
| Install otomatis | ✅ | Manual | Manual |
| Cocok untuk | Starter cepat | Kustomisasi penuh | API / Mobile / SPA |
💡 Ingat!
Dua hal yang paling sering bikin bingung di auth Laravel:
Pertama — Breeze bukan pengganti Fortify. Breeze menggunakan Fortify. Kalau kamu install Breeze, Fortify sudah ada di dalamnya. Kalau kamu mau kustomisasi logika login, kamu edit di Fortify — bukan di Breeze.
Kedua — middleware auth dan auth:sanctum itu berbeda. auth (tanpa guard) menggunakan session — cocok untuk route web. auth:sanctum membaca token dari header Authorization — cocok untuk route API. Salah pilih guard, request yang valid pun bisa ditolak.
Dan satu kebiasaan yang sangat direkomendasikan: selalu pisahkan route web di routes/web.php dan route API di routes/api.php. Keduanya punya middleware group yang berbeda — web group punya session dan CSRF, api group tidak.