List pages
This page shows you how to build a record list with search, filters, sorting and pagination by hand, using the WithListing trait and Acun components.
The trait handles search, sorting, pagination and bulk selection for you; you write the table and the filters. If you want a ready-made table whose columns and filters you define in PHP, see the DataTable guide.
Note
WithListingand search are part of the PRO package, which comes installed with Acun themes. All components on this page (acun:table,acun:sortable-th,acun:search,acun:selection-bar…) are free. You can also use them with your own query.
Quick example
app/Livewire/Customers/Index.php:
namespace App\Livewire\Customers;
use Acun\Ui\Livewire\Concerns\WithListing;
use Acun\Ui\Search\SearchField;
use Acun\Ui\Search\SearchMode;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.admin.vertical')]
#[Title('Customers')]
final class Index extends Component
{
use WithListing;
// The list is sorted by this column when it first opens.
protected function defaultSortField(): string
{
return 'name';
}
// Columns the user can sort by.
protected function sortableFields(): array
{
return ['name', 'email', 'created_at'];
}
// Search: a word anywhere in the name, the start of the email.
protected function searchFields(): array
{
return [
SearchField::make('name', SearchMode::Contains),
SearchField::make('email'),
];
}
// IDs of the rows on the current page (bulk selection uses this).
protected function currentPageKeys(): array
{
return $this->query()->forPage($this->getPage(), $this->perPage)->pluck('id')->all();
}
private function query(): Builder
{
return $this->applySearch(Customer::query())
->orderBy($this->sortField, $this->sortDirection);
}
public function render()
{
$this->fillPageSelection();
return view('admin.customers.index', ['customers' => $this->query()->paginate($this->perPage)]);
}
}
resources/views/admin/customers/index.blade.php:
<section class="mx-auto w-full max-w-7xl space-y-6">
<acun:page-header :title="__('Customers')" />
<acun:list-toolbar>
<acun:slot:start><acun:per-page-select :options="$this->pageSizeOptions()" /></acun:slot:start>
<acun:search wire:model.live.debounce.300ms="search" placeholder="Search by name or email" />
</acun:list-toolbar>
<acun:table>
<table class="ui-table">
<thead>
<tr>
<acun:sortable-th field="name" :sort-field="$sortField" :sort-direction="$sortDirection">Name</acun:sortable-th>
<acun:sortable-th field="email" :sort-field="$sortField" :sort-direction="$sortDirection">Email</acun:sortable-th>
</tr>
</thead>
<tbody>
@foreach ($customers as $customer)
<tr wire:key="customer-{{ $customer->id }}">
<td>{{ $customer->name }}</td>
<td>{{ $customer->email }}</td>
</tr>
@endforeach
</tbody>
</table>
</acun:table>
{{ $customers->links() }}
</section>
The search runs on the name_search and email_search columns that the database generates. Add these columns in a migration:
Schema::table('customers', function (Blueprint $table) {
$table->searchColumn('name', index: false); // searched anywhere; an index wouldn't help
$table->searchColumn('email'); // searched from the start; indexed
});
Now the list narrows as you type in the search box, and clicking a header sorts it. Turkish letters and letter case don't matter: typing "ayse" finds "Ayşe". The search text and the sort order are kept in the address bar as ?q=, ?sort= and ?direction=. Add the route and the menu entry as shown in Your first page.
Common options
Methods you write in your component:
| Method | What it does | Example |
|---|---|---|
defaultSortField() |
The default sort column (required) | 'name' |
currentPageKeys() |
IDs of the rows on the current page (required) | the example above |
sortableFields() |
Columns that can be sorted; by default only defaultSortField() |
['name', 'email'] |
perPageOptions() |
Page sizes to choose from; by default [10, 25, 50, 100] |
[10, 25, 50] |
searchFields() |
Fields to search; see Search | [SearchField::make('email')] |
Adding a filter
A filter is a property of your own component. With #[Url], it's kept in the address bar. When it changes, go back to the first page and clear the selection:
use Livewire\Attributes\Url;
#[Url(history: true)]
public string $status = '';
public function updated(string $property): void
{
if ($property === 'status') {
$this->resetPage();
$this->clearSelection();
}
}
public function activeFilterCount(): int
{
return $this->status !== '' ? 1 : 0;
}
Apply the filter in the query: ->when($this->status !== '', fn (Builder $q) => $q->where('status', $this->status)). Put the filter fields in a drawer; acun:filter-button opens the drawer and shows the number of active filters:
<acun:list-toolbar>
<acun:search wire:model.live.debounce.300ms="search" placeholder="Search by name or email" />
<acun:slot:actions>
<acun:filter-button drawer="customer-filters" :count="$this->activeFilterCount()" />
</acun:slot:actions>
</acun:list-toolbar>
<acun:drawer name="customer-filters" docked title="Filters" compact>
<acun:select wire:model.live="status" label="Status" :options="['' => 'All', 'active' => 'Active', 'passive' => 'Passive']" />
</acun:drawer>
Adding bulk actions
Add a checkbox to the header and to every row. Place the selection bar after the table and the pagination:
{{-- first cell of the <thead> row --}}
<th class="w-10"><acun:checkbox wire:model.live="pageSelected" :checked="$pageSelected" aria-label="Select all" /></th>
{{-- first cell of each row inside @foreach --}}
<td><acun:checkbox wire:model.live="selected" :value="$customer->id" :id="'customer-'.$customer->id" aria-label="Select" /></td>
{{-- after the {{ $customers->links() }} line --}}
<acun:selection-bar :count="$this->selectionCount($customers->total())" :all-matching="$allMatchingSelected" :total="$this->selectionCount($customers->total())">
<acun:button variant="soft-danger" size="sm" wire:click="bulkArchive">Archive</acun:button>
</acun:selection-bar>
In a bulk action, always apply the selection to the page's own query with applySelection():
public function bulkArchive(): void
{
$this->query()
->tap(fn (Builder $q) => $this->applySelection($q))
->update(['archived' => true]);
$this->clearSelection();
}
The header checkbox selects every record matching the search and filters, across all pages. In that state, a row you uncheck goes into the $excluded list, and the bulk action doesn't touch it. Changing the page or the page size doesn't clear the selection. Changing the search or a filter does. The bar's "Clear selection" button calls clearSelection().
Security
orderBy($this->sortField, $this->sortDirection)is safe to use. On every request, the trait checks the sort and page size values against the allowed lists.- In bulk actions, don't use the selected IDs directly with
whereKey($this->selected). Apply them to the page's query withapplySelection(). That way, records the user can't see are never touched, and the other pages and unchecked rows are taken into account.
Advanced
The trait's properties and methods
| Property / method | What it does |
|---|---|
$search |
The search text; ?q= in the address bar |
$sortField, $sortDirection |
Sorting; ?sort= and ?direction= in the address bar |
$perPage |
Page size; 25 by default |
$selected, $pageSelected |
The selected records and the header checkbox |
$allMatchingSelected, $excluded |
Whether all matching records are selected; rows unchecked meanwhile |
applySelection($query) |
Narrows a bulk action query to the selection |
selectionCount($total) |
The number of selected records |
sortBy($field) |
Changes the sort order; acun:sortable-th calls it |
clearSelection() |
Clears the selection; called automatically when the search changes |
applySearch($query, $term = null, $fields = null) |
Searches for $search in the searchFields() fields |
searchDriver() |
The search driver; acun-ui.search.driver by default |
pageSizeOptions() |
Page sizes for the view; comes from perPageOptions() |
fillPageSelection() |
While "all" is selected, adds the new page's rows to the selection |
Call fillPageSelection() at the start of render(). In single-file components, call it at the start of with().
How values are checked
$sortField, $sortDirection and $perPage can be changed from the browser. The trait corrects them on every request:
- A column not in
sortableFields()falls back to the default column. - A direction other than
ascordescbecomesasc. - A size not in
perPageOptions()falls back to the first value in the list.
Any value you assign to $perPage in mount() must also be in perPageOptions(). The example below sets the page size to 10. If the address bar has no ?direction=, it opens the list in descending order:
public function mount(): void
{
$this->perPage = 10;
if (! request()->query->has('direction')) {
$this->sortDirection = 'desc';
}
}
A second search box
applySearch() also accepts another term and field list. For details and search columns, see the Search guide.
Learn more
- A complete, working example: in the demo app, the "Manually with WithListing" tab of Tables › DataTable › Server-side (Ajax).
- DataTable: a ready-made table whose columns you define in PHP.
- DataTable examples
- Search: search columns, modes and large tables.
- Form modal: adding and editing records from the list.