A Laravel data table with sorting, filters and pagination
A Blade table over an Eloquent paginator: sortable columns, filters that apply as people type, option counts and a shared URL, with every query parameter checked.
php artisan bladewell:add table search select
Uses Table, Search and Select. New to Bladewell? Install it first.
A paginator from the controller
The table takes what you'd pass any Blade view: a paginator, or any collection. Sorting and filters arrive as ordinary query parameters, so the controller is plain Eloquent. The one rule is to check every parameter against your own list before it reaches the query. Never pass the query string straight to orderBy().
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OrderController extends Controller
{
private const STATUSES = ['paid' => 'Paid', 'pending' => 'Pending', 'refunded' => 'Refunded', 'failed' => 'Failed'];
private const SORTABLE = ['number', 'status', 'created_at', 'amount'];
public function index(Request $request): View
{
// Every parameter is checked against our own list before it reaches the query.
$search = mb_substr(trim((string) $request->query('q')), 0, 100);
$status = array_key_exists((string) $request->query('status'), self::STATUSES) ? $request->query('status') : null;
$sort = in_array($request->query('sort'), self::SORTABLE, true) ? $request->query('sort') : 'created_at';
$direction = $request->query('direction') === 'asc' ? 'asc' : 'desc';
$orders = Order::query()
->when($search !== '', fn ($query) => $query->where('number', 'like', '%'.addcslashes($search, '%_\\').'%'))
->when($status, fn ($query) => $query->where('status', $status))
->orderBy($sort, $direction)
->paginate(10)
->withQueryString();
return view('orders.index', [
'orders' => $orders,
'statuses' => self::STATUSES,
'status' => $status,
]);
}
}
withQueryString() keeps the filters and sort on every page link. The addcslashes stops a search for % from matching everything.
The table
Describe the columns once, mark the ones people can sort by, and put the filter fields in the filters slot. You write the rows yourself, so a cell can hold anything: a badge, a link, a formatted amount.
<x-widget.table
id="orders"
caption="Orders"
:rows="$orders"
pagination="numbers"
while-loading="skeleton"
empty="No orders match these filters"
:columns="[
['label' => 'Order', 'key' => 'number', 'sortable' => true],
['label' => 'Customer'],
['label' => 'Status', 'key' => 'status', 'sortable' => true],
['label' => 'Date', 'key' => 'created_at', 'sortable' => true],
['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
]"
>
<x-slot:filters>
<x-widget.search name="q" placeholder="Search orders" />
<x-widget.select name="status" label="Status" inner-label clearable placeholder="Any" :options="$statuses" :value="$status" />
</x-slot:filters>
@foreach ($orders as $order)
<x-widget.table.row>
<td>{{ $order->number }}</td>
<td>{{ $order->customer_name }}</td>
<td><x-widget.table.badge :tone="['paid' => 'success', 'pending' => 'warning', 'refunded' => 'info', 'failed' => 'error'][$order->status]">{{ $statuses[$order->status] }}</x-widget.table.badge></td>
<td>{{ $order->created_at->toFormattedDateString() }}</td>
<td>{{ Number::currency($order->amount) }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
What happens in the browser
- Clicking a sortable header sorts by it: ascending, then descending, then back to unsorted. The header says which way for screen readers.
- The table filters as people type, after a short pause, or as they pick. It goes back to page 1 and keeps the sort.
- Rows update in place, without a full page reload.
while-loading="skeleton"shows placeholder rows while a slower page loads. - The address bar follows, so a reload, the Back button or a shared link shows the same rows.
- With no rows, the empty state says so and offers Clear filters.
- Without JavaScript it's still a working GET form with links.
When you need more
Each of these is a live example on the table's page, with its code:
- Option counts beside each filter value, multi-column sort with Shift+click, a Columns menu, a totals row and a short cache for pages already seen.
- Row selection with bulk actions.
- Clickable and expandable rows.
- Stacked cards on phones, and infinite scroll with a cursor paginator.
In a Livewire component
Keep the state in properties, bind the filter fields with wire:model, and pass :sort and :direction to the table. Its page and sort links then update the component instead of the page. Every public property can be set from the browser, so check them in render() as you would query parameters.
namespace App\Livewire;
use App\Models\Order;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Orders extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public ?string $sort = null;
#[Url]
public string $direction = 'desc';
public function updatedSearch(): void
{
$this->resetPage();
}
public function render()
{
// Public properties can be set from the browser too: check them like query parameters.
$sort = in_array($this->sort, ['number', 'status', 'created_at', 'amount'], true) ? $this->sort : 'created_at';
$search = mb_substr(trim($this->search), 0, 100);
return view('livewire.orders', [
'orders' => Order::query()
->when($search !== '', fn ($query) => $query->where('number', 'like', '%'.addcslashes($search, '%_\\').'%'))
->orderBy($sort, $this->direction === 'asc' ? 'asc' : 'desc')
->paginate(10),
]);
}
}
<div>
<x-widget.table
id="orders"
caption="Orders"
:rows="$orders"
:sort="$sort"
:direction="$direction"
:columns="[
['label' => 'Order', 'key' => 'number', 'sortable' => true],
['label' => 'Date', 'key' => 'created_at', 'sortable' => true],
['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
]"
>
<x-slot:filters>
<x-widget.search name="search" wire:model.live.debounce.300ms="search" placeholder="Search orders" />
</x-slot:filters>
@foreach ($orders as $order)
<x-widget.table.row wire:key="order-{{ $order->id }}">
<td>{{ $order->number }}</td>
<td>{{ $order->created_at->toFormattedDateString() }}</td>
<td>{{ Number::currency($order->amount) }}</td>
</x-widget.table.row>
@endforeach
</x-widget.table>
</div>
Give each row a wire:key, so open rows and ticked boxes stay with their record when the component re-renders.