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>
- Route htmx ditulis di
routes/web.php, bukanapi.php: route web punya session, cookie, dan perlindungan CSRF. hx-boost="true"di<body>membuat semua link di<nav>dikirim htmx. Laravel tetap merender halaman penuh, htmx menukar isi<body>, dan<head>gak dimuat ulang.htmx-configmengizinkan balasan 422 ditukar (dibahas di bagian validasi),hx-headersmembawa token CSRF, danhx-on:toastmenampilkan notifikasi dari server.
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');
}
$request->header('HX-Request')membaca nilai header itu, yaitu"true", ataunullkalau gak ada.$request->hasHeader('HX-Request')menjawabtrueataufalse.fragmentIf($potongan, 'papan')merender view-nya, tapi yang dikirim cuma isi antara@fragment('papan')dan@endfragment. Tanpa syarat:->fragment('papan'). Untuk beberapa sekaligus:fragments([...]).- Hasilnya lebih ringan dan cepat. Di Lab, halaman penuh 1.486 byte, potongan
papan346 byte. Di aplikasi sungguhan, selisihnya jauh lebih besar. Vary: HX-Requestmemberi tahu cache (browser atau CDN) bahwa satu alamat punya dua bentuk. Tanpa itu, cache bisa menyajikan potongan polling sebagai halaman/papan.
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
- Request POST, PUT, PATCH, dan DELETE melewati middleware
PreventRequestForgery(Laravel 13). Dia meloloskan request yang headerSec-Fetch-Site-nyasame-origin. Header itu gak selalu dikirim (browser lama, alamat yang bukan HTTPS), dan saat itu yang diperiksa token: field_tokenatau headerX-CSRF-TOKEN. hx-headersdi<body>diwariskan ke semua elemen, jadi tombolhx-postatauhx-deletedi luar form ikut membawa token.- Form tetap memakai
@csrf(input tersembunyi_token), supaya tetap jalan tanpa JavaScript. - Token salah atau gak ada: 419 Page Expired, yang gak ditukar htmx. Tombolnya terlihat gak bereaksi.
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"]));
}
- Untuk request htmx, form dibalas lagi dengan 422.
$request->flash()membuatold('nama')di partial terisi, danwithErrors()membuat@error('berat')menampilkan "Berat maksimal 30 kg.". HX-Retarget: #terimadanHX-Reswap: outerHTMLmembuat form itu yang ditukar, bukan papan. htmx baru menukar 422 karena layout mengizinkannya lewathtmx-config. Cara lain: extension[email protected]denganhx-ext="response-targets"danhx-target-422, yang tetap memakaihx-swapform-nya.- Tanpa JavaScript,
$validator->validate()melempar exception, dan Laravel melakukan yang biasa: redirect balik dengan error dan isian. papan/_order-baru.blade.phpberisi baris baru,<span id="jumlah-aktif" hx-swap-oob="true">, dan form kosong ber-hx-swap-oob="true", seperti balasan sukses di proyek papan.
HX-Trigger untuk toast, HX-Redirect untuk pindah halaman
HX-Trigger: {"toast":"Order #15 diterima"}memicu eventtoastdi form yang mengirim. Event-nya naik ke<body>, danhx-on:toastmenulisevent.detail.valueke#toast.redirect()->route('laporan')dari request htmx diikuti XHR diam-diam, jadi halaman laporan ditaruh di target tombolnya. Untuk pindah halaman sungguhan:
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
- Membalas halaman penuh ke setiap request htmx. Seluruh layout masuk ke dalam target. Pakai
@fragmentdanfragmentIf. - Memeriksa
HX-Requestsaja. Link yang di-boost ikut dapat potongan. Tambahkan! $request->hasHeader('HX-Boosted'). $request->validate()atauredirect()untuk request htmx. Redirect-nya diikuti XHR diam-diam. Balas 422 denganHX-Retarget, atauHX-Redirect.hx-boostdi elemen yang gak membungkus link-nya. Di latihan,hx-boost="true"dipasang di<body>, nilainya persistrue, dan ketiga link di<nav>ada di dalamnya, bersama<main id="content">.<body>yang sama juga membawahx-headersdenganX-CSRF-TOKEN.
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.