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

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

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

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 TestFeature TestIntegration Test
Kecepatan⚡ Sangat cepat🏃 Sedang🐢 Paling lambat
Database❌ Tidak✅ (refresh)✅ Nyata
HTTP❌ Tidak✅ Simulasi✅ Simulasi
Mock?✅ Semua dep.✅ External saja❌ Tidak
Lokasitests/Unit/tests/Feature/tests/Feature/Integration/
FokusLogicAlur fiturIntegrasi 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?

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

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.

Aturan Event & Listener

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

KomponenPeran
EventApa yang terjadi di server
Broadcast ChannelSiapa yang boleh mendengar
BroadcasterServer WebSocket (Pusher / Soketi / Ably)
Laravel EchoListener 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

ObserverEvent & ListenerBroadcast Event
TriggerOtomatis (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)ShouldQueueShouldQueue
Sampai ke browser✅ Via WebSocket
Cocok untukData hygieneBusiness flowReal-time UI

💡 Ingat!

Satu kalimat yang merangkum semuanya:

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:

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

DatabaseRedisSQS
SetupSangat mudahPerlu Redis serverPerlu akun AWS
KecepatanLambatPaling cepatSedang
ReliabilitySedangButuh config AOF/RDBPaling reliable
ScalabilityTerbatasBaikAuto-scale
BiayaGratisServer RedisPer request
Cocok untukDev / kecilProduction umumCloud-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:

Jangan pakai Queue untuk:


💡 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:

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:

Yang tidak perlu versi baru:


📊 Perbandingan Singkat

Tanpa ResourceDengan 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+1whenLoaded()
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:

  1. “Apakah user ini boleh masuk ke halaman dashboard admin?”
  2. “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

Kodenya

📁 app/Providers/AuthServiceProvider.php

Semua Gate::define() idealnya didaftarkan di sini, di dalam method boot().

<?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

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

GatePolicy
BentuknyaClosureClass
Terikat model
Butuh objek spesifik
Cocok untukAturan globalAturan per-model
Tempat daftarAuthServiceProviderapp/Policies/
Contoh penggunaancan:view-dashboardcan: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

BreezeFortifySanctum
Menyediakan UI✅ Blade / Inertia
Logika authLewat Fortify✅ Langsung
Untuk web (session)✅ (SPA)
Untuk API (token)
Install otomatisManualManual
Cocok untukStarter cepatKustomisasi penuhAPI / 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.