HTMX dengan Laravel — HTMX

HTMX dan Laravel adalah kombinasi yang sangat natural — server-rendered HTML + Blade templates. Blade Partial // routes/web.php Route::get("/todos", function…

Laravel merender HTML dengan Blade, dan htmx cuma meminta HTML. Jadi papan order Bu Rini bisa dilayani Laravel apa adanya: route yang sama, view yang sama, cuma bentuk balasannya yang menyesuaikan. Bayangin percetakan langganan Bu Rini. Untuk pelanggan baru, percetakan mengirim brosur lengkap: sampul, daftar layanan, semua halaman. Kalau kasir cuma minta halaman harga yang baru, percetakan mengirim selembar itu saja, dari cetakan yang sama. Laravel percetakannya, Blade cetakannya, dan header HX-Request: true di request adalah catatan "cukup selembar".

🧪 Tebakan cepat: Controller papan membalas potongan setiap kali request membawa HX-Request. Layout-nya memakai <body hx-boost="true">. Kasir mengklik link Papan di menu. Apa yang tampil? Jawabannya ada di bagian "hx-boost juga membawa HX-Request".

Route dan layout

// routes/web.php
use App\Http\Controllers\PapanController;
use Illuminate\Support\Facades\Route;

Route::get('/papan', [PapanController::class, 'index'])->name('papan');
Route::post('/order', [PapanController::class, 'store'])->name('order.store');
Route::post('/tutup-buku', [PapanController::class, 'tutupBuku'])->name('tutup-buku');
Route::view('/laporan', 'laporan')->name('laporan');
{{-- resources/views/layouts/app.blade.php --}}
<!doctype html>
<html lang="id">
<head>
  <meta charset="utf-8">
  <title>@yield('judul') | Laundry Bu Rini</title>
  <meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true},{"code":"...","swap":true}]}'>
  <script src="https://unpkg.com/[email protected]"></script>
</head>
<body hx-boost="true" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
      hx-on:toast="document.getElementById('toast').textContent = event.detail.value">
  <nav>
    <a href="/papan">Papan</a> <a href="/laporan">Laporan</a> <a href="/harga">Harga</a>
  </nav>
  <main id="content">
    @yield('isi')
  </main>
  <p id="toast" role="status"></p>
</body>
</html>

Satu view, dua bentuk: @fragment

{{-- resources/views/papan/index.blade.php --}}
@extends('layouts.app')
@section('judul', 'Papan order')
@section('isi')
  <h1>Papan order · <span id="jumlah-aktif">{{ $orders->count() }} order aktif</span></h1>
  <button hx-get="/papan" hx-target="#papan-order" hx-swap="outerHTML">Muat ulang papan</button>
  @fragment('papan')
    <ul id="papan-order" hx-get="/papan" hx-trigger="every 30s" hx-swap="outerHTML">
      @foreach ($orders as $order)
        @include('papan._baris', ['order' => $order])
      @endforeach
    </ul>
  @endfragment
  @include('papan._form')
  <button hx-post="/tutup-buku">Tutup buku</button>
@endsection
public function index(Request $request): Response
{
    $orders = Order::orderBy('id')->get();
    $potongan = $request->hasHeader('HX-Request') && ! $request->hasHeader('HX-Boosted');

    return response(view('papan.index', ['orders' => $orders])->fragmentIf($potongan, 'papan'))
        ->header('Vary', 'HX-Request');
}

hx-boost juga membawa HX-Request

Link yang di-boost dikirim htmx, jadi ikut membawa HX-Request: true, ditambah HX-Boosted: true. Kalau controller cuma memeriksa HX-Request, klik Papan di menu dibalas potongan <ul>, dan htmx menukar seluruh isi <body> dengan daftar itu: menu, judul, dan form hilang. Itu jawaban tebakannya. Makanya syaratnya hasHeader('HX-Request') && ! hasHeader('HX-Boosted'): link yang di-boost tetap dapat halaman penuh.

CSRF: token di setiap request

Validasi gagal: 422 ke form

htmx gak mengirim X-Requested-With, jadi bagi Laravel request-nya bukan AJAX. $request->validate() yang gagal membalas 302 ke halaman sebelumnya. XHR mengikuti redirect itu diam-diam (header HX-Request ikut), htmx menerima papan versi potongan dengan status 200, lalu menempelkannya di akhir papan: papan dobel, dan pesan error-nya gak pernah tampil. Tangani sendiri:

public function store(Request $request): Response|RedirectResponse
{
    $validator = Validator::make($request->all(), [
        'nama' => ['required', 'string', 'max:50'],
        'berat' => ['required', 'numeric', 'min:1', 'max:30'],
    ], ['berat.max' => 'Berat maksimal :max kg.']);

    if ($validator->fails() && $request->hasHeader('HX-Request')) {
        $request->flash();

        return response(view('papan._form')->withErrors($validator), 422)
            ->header('HX-Retarget', '#terima')
            ->header('HX-Reswap', 'outerHTML');
    }

    $order = Order::create([...$validator->validate(), 'status' => 'Antre']);

    if (! $request->hasHeader('HX-Request')) {
        return redirect()->route('papan');
    }

    return response(view('papan._order-baru', ['order' => $order, 'aktif' => Order::count()]))
        ->header('HX-Trigger', json_encode(['toast' => "Order #{$order->id} diterima"]));
}

HX-Trigger untuk toast, HX-Redirect untuk pindah halaman

public function tutupBuku(): Response
{
    return response()->noContent()->header('HX-Redirect', route('laporan'));
}

📌 Paket bantu (opsional)

composer require mauricius/laravel-htmx (v0.10, untuk Laravel 12 dan 13) memberi HtmxRequest dengan isHtmxRequest() dan isBoosted(), plus kelas balasan seperti HtmxResponseClientRedirect. Semua yang di atas tetap bisa ditulis tanpa paket ini.

⚠️ Jebakan yang sering ketemu

TL;DR: htmx dan Laravel cocok karena Laravel merender HTML. Satu route melayani dua bentuk: halaman penuh, atau potongan @fragment lewat view(...)->fragmentIf($request->hasHeader('HX-Request') && ! $request->hasHeader('HX-Boosted'), 'papan'), plus Vary: HX-Request. $request->header('HX-Request') membaca header-nya, dan potongan lebih ringan dan cepat (1.486 byte jadi 346 byte di Lab). hx-boost="true" di <body> membuat link pindah halaman tanpa reload, dengan HX-Boosted: true. Token CSRF dibawa hx-headers sebagai X-CSRF-TOKEN: tanpa token dan tanpa Sec-Fetch-Site: same-origin, balasannya 419. Validasi gagal dibalas 422 berisi form, HX-Retarget dan HX-Reswap, yang ditukar karena htmx-config mengizinkannya. HX-Trigger memicu toast, dan HX-Redirect memindahkan browser, karena redirect() biasa diikuti XHR diam-diam.

Yang akan kamu pelajari