Membangun CRUD lengkap dengan Eloquent di satu komponen Livewire, menambah pagination dengan trait WithPagination, dan membuat filter serta pencarian URL-driven dengan attribute #[Url] agar state bisa dibagikan dan di-bookmark.

Kita sudah menguasai form dan validasi. Sekarang saatnya menyatukan semuanya menjadi pola yang paling sering ditemui di aplikasi nyata: CRUD (Create, Read, Update, Delete) dengan Eloquent — plus dua fitur yang menentukan UX halaman data: pagination dan query string.
Mengapa query string penting? Coba bayangkan user sedang menyaring data di halaman 3 dengan kata kunci tertentu, lalu me-refresh browser. Tanpa query string, semua state filter hilang — user kembali ke halaman pertama tanpa filter. Dengan #[Url], state terenkripsi di URL, sehingga filter, pencarian, dan halaman bisa di-bookmark dan dibagikan.
Kita gabungkan komponen PostsIndex yang menangani seluruh alur:
<?php
namespace App\Livewire;
use Livewire\Attributes\Url;
use Livewire\WithPagination;
use Livewire\Component;
use App\Models\Post;
class PostsIndex extends Component
{
use WithPagination;
#[Url(history: true)]
public string $search = '';
#[Url(history: true)]
public string $status = '';
public function render()
{
return view('livewire.posts-index', [
'posts' => Post::query()
->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('created_at')
->paginate(10),
]);
}
}<div>
<input type="search" wire:model.live.debounce.300ms="search" placeholder="Cari judul...">
<select wire:model.live="status">
<option value="">Semua status</option>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
<table>
@foreach ($posts as $post)
<tr wire:key="post-{{ $post->id }}">
<td>{{ $post->title }}</td>
<td>{{ $post->status }}</td>
<td>
<button wire:click="edit({{ $post->id }})">Edit</button>
<button wire:click="delete({{ $post->id }})" wire:confirm="Yakin hapus?">Hapus</button>
</td>
</tr>
@endforeach
</table>
{{ $posts->links() }}
</div>Perhatikan pola ->when($condition, fn ($q) => ...): query dibangun kondisional sehingga tidak ada filter tidak berarti query penuh terpaksa kosong.
Gunakan Form Object dari episode 10 agar dua halaman berbagi satu logika:
use App\Livewire\Forms\PostForm;
use Livewire\Component;
use App\Models\Post;
class PostEditor extends Component
{
public PostForm $form;
public ?Post $post = null;
public function mount(?Post $post = null)
{
$this->post = $post;
if ($post) {
$this->form->bind($post);
}
}
public function save()
{
$this->post
? $this->form->update($this->post)
: $this->post = $this->form->store();
session()->flash('status', 'Post disimpan.');
$this->redirect(route('posts.index'));
}
}Komponen yang sama menangani create (tanpa $post) maupun update (dengan $post). route('posts.index') bisa menerima object model — Laravel otomatis memakai route key.
Delete cukup langsung — dan selalu konfirmasi dari view:
use Livewire\WithPagination;
public function delete(int $postId)
{
Post::findOrFail($postId)->delete();
$this->resetPage(); // cegah halaman kosong setelah item terakhir dihapus
}$this->resetPage() penting: jika user berada di halaman terakhir dan menghapus satu-satunya item di sana, pagination akan kembali ke halaman kosong. Reset mengembalikan ke halaman pertama.
Warning
Jangan pernah meng-hardcode delete tanpa otorisasi dan konfirmasi. wire:confirm hanya melindungi dari klik tak sengaja; cek authorize() untuk memastikan user berhak menghapus (episode 13). Di produksi, pertimbangkan soft delete alih-alih delete() langsung.
Trait WithPagination memberi komponen kemampuan pagination yang terhubung dengan query string:
use Livewire\WithPagination;
class PostsIndex extends Component
{
use WithPagination;
// ...
}Post::paginate(10) di render → hasilnya diakses sebagai $posts.{{ $posts->links() }} merender navigasi halaman.?page=2) dan tidak men-trigger reload.Saat filter/search berubah, halaman harus kembali ke 1 — tambahkan reset pada hook:
public function updatedSearch()
{
$this->resetPage();
}
public function updatedStatus()
{
$this->resetPage();
}Attribute #[Url] menghubungkan public property ke URL:
use Livewire\Attributes\Url;
#[Url]
public string $search = '';
#[Url(history: true, as: 'status-filter')]
public string $status = '';#[Url] tanpa argumen → property disinkronkan ke query string (?search=...).history: true → perubahan ditulis ke history browser, jadi tombol back/forward berfungsi.as: 'status-filter' → alias nama query string bila ingin berbeda dari nama property.Manfaatnya konkret: halaman hasil pencarian bisa di-share, di-refresh tanpa kehilangan state, dan dianalisis lewat log server. Tanpa #[Url], state filter hanya hidup di dalam sesi request.
Tip
Kombinasi terbaik: wire:model.live.debounce untuk pencarian + #[Url(history: true)] untuk state-nya. User mengetik → URL ikut berubah → refresh/share tidak kehilangan filter → tombol back browser tetap masuk akal.
use WithPagination;: paginate() tetap jalan tapi tanpa tracking URL dan tanpa integrasi Livewire.resetPage() saat filter berubah: user di halaman 5 lalu mencari kata kunci → hasil bisa kosong atau tidak terduga.wire:key hilang di baris tabel: morphing kacau saat list berubah (episode 5).->when(): filter kosong menghasilkan kondisi WHERE status = '' yang salah; pakai pola kondisional.Inti yang harus dibawa pulang:
resetPage().WithPagination + paginate(10) + $posts->links(); reset page saat filter berubah.#[Url] membuat state filter/search tersinkron ke URL — bisa di-share dan di-refresh.->when() untuk query kondisional yang bersih.Di episode 12 selanjutnya kita akan membahas file uploads & downloads — WithFileUploads, validasi file, preview, progress upload dengan Alpine, serta streaming downloads dan penanganan temp files. Sampai jumpa di episode 12!