Table Sorting and Pagination with HTMX in Laravel
Photo by Benjamin Ashton on Unsplash
In my last post, we went through the basic of getting started with HTMX in Laravel. We explored few core concepts including usage of hx-get
and hx-post
to add asynchronous functionality.
In this post, we’ll enhance a laravel blade template with HTMX to add client-side sorting and pagination.
Introduction
We build a very basic data table for contacts
CRUD in previous post, now let's extend that with features like pagination and sorting, and also make it a reusable component.
Pagination with HTMX in Laravel
While using {{ $contacts->links() }}
in Laravel gives pagination links, it lacks reactivity. This is where the hx-boost
attribute helps.
We apply the hx-boost
attribute to the container, making sure subsequent requests from links within the container receive the hx-get
treatment.
Here's how our code looks like:
<div id="table-container"
hx-get="{{ route('contacts.index') }}"
hx-trigger="loadContacts from:body">
<table id="contacts-table" class="table-auto w-full">
...
</table>
<div id="pagination-links" class="p-3"
hx-boost="true"
hx-target="#table-container">
{{ $contacts->links() }}
</div>
</div>
Additionally, we moved the hx-get
and hx-trigger
attributes from the table to the parent container div
.
Here's the updated ContactsController
:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ContactRequest;
use App\Models\Contact;
use Illuminate\Http\Request;
class ContactController extends Controller
{
public function index(Request $request)
{
$searchTerm = $request->input('q');
$contacts = Contact::where('name', 'LIKE', "%$searchTerm%")
->paginate(10);
if ($request->header('hx-request')
&& $request->header('hx-target') == 'table-container') {
return view('contacts.partials.table', compact('contacts'));
}
return view('contacts.index', compact('contacts'));
}
...
...
}
Now, when a pagination link is clicked, HTMX sends an asynchronous request and swaps contents upon receiving a response.
Sorting with HTMX in Laravel
Sorting data is another common requirement. We aim to rearrange table rows by clicking column headers. Let's enable asynchronous sorting with HTMX.
In the table header, we include the required HTMX attributes for interactive sorting:
<div id="table-container"
hx-get="{{ route('contacts.index') }}"
hx-trigger="loadContacts from:body">
@php
$sortField = request('sort_field');
$sortDir = request('sort_dir', 'asc') === 'asc' ? 'desc' : 'asc';
$sortIcon = fn($field) =>
$sortField === $field ? ($sortDir === 'asc' ? '↑' : '↓') : '';
$hxGetUrl = fn($field) =>
request()->fullUrlWithQuery([
'sort_field' => $field,
'sort_dir' => $sortDir
]);
@endphp
<table id="contacts-table" class="table-auto w-full">
<thead>
<th class='px-4 py-2 border text-left cursor-pointer'
hx-get="{{ $hxGetUrl('name') }}"
hx-trigger='click'
hx-replace-url='true'
hx-swap='outerHTML'
hx-target='#table-container'>
Name
<span class="ml-1" role="img">{{ $sortIcon('name') }}</span>
</th>
<th class='px-4 py-2 border text-left cursor-pointer'
hx-get="{{ $hxGetUrl('email') }}"
hx-trigger='click'
hx-replace-url='true'
hx-swap='outerHTML'
hx-target='#table-container'>
Email
<span class="ml-1" role="img">{{ $sortIcon('email') }}</span>
</th>
<th class="px-4 py-2 border text-left">Phone</th>
<th class="px-4 py-2 border text-left">Address</th>
<th class="px-4 py-2 border text-left">Actions</th>
</thead>
<tbody id="contacts-table-body"
...
...
</tbody>
</table>
<div id="pagination-links" class="p-3"
hx-boost="true"
hx-target="#table-container">
{{ $contacts->links() }}
</div>
</div>
Within the @php
tags, we define variables to handle sorting functionalities. The $sortField
and $sortDir
variables manage the field and direction for sorting, while the $sortIcon
function generates the appropriate arrow icon for indicating the sorting direction. The $hxGetUrl
function helps create the URL with updated sort parameters for HTMX requests.
And here's the updated ContactsController
once again,
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ContactRequest;
use App\Models\Contact;
use Illuminate\Http\Request;
class ContactController extends Controller
{
public function index(Request $request)
{
$searchTerm = $request->input('q');
$contacts = Contact::where('name', 'LIKE', "%$searchTerm%")
->when($request->has('sort_field'), function ($query) use ($request) {
$sortField = $request->input('sort_field');
$sortDir = $request->input('sort_dir', 'asc');
$query->orderBy($sortField, $sortDir);
})
->paginate(10);
if ($request->header('hx-request')
&& $request->header('hx-target') == 'table-container') {
return view('contacts.partials.table', compact('contacts'));
}
return view('contacts.index', compact('contacts'));
}
...
...
}
Refactoring with Blade Components
Our table code is becoming lengthy and lacks reusability. To address this, we can separate each table element into its own component.
Let's break it down into reusable components:
components
├── table
│ ├── actions
│ │ ├── delete.blade.php
│ │ ├── edit.blade.php
│ │ └── view.blade.php
│ ├── tbody.blade.php
│ ├── td.blade.php
│ ├── th.blade.php
│ ├── thead.blade.php
│ └── tr.blade.php
└── table.blade.php
The following commands will assist you in creating these components:
php artisan make:component table --view
php artisan make:component table.td --view
php artisan make:component table.th --view
php artisan make:component table.tr --view
php artisan make:component table.thead --view
php artisan make:component table.tbody --view
php artisan make:component table.actions.delete --view
php artisan make:component table.actions.edit --view
php artisan make:component table.actions.view --view
The primary table component x-table
appears as follows:
@props(['columns', 'records'])
<table {{ $attributes->merge(['id' => 'table','class' => 'table-auto w-full']) }}>
@if(isset($columns))
<x-table.thead :columns="$columns"/>
@if(isset($records))
<x-table.tbody :columns="$columns" :records="$records"/>
@endif
@endif
{{ $slot }}
</table>
This code establishes slots for the header and body, transmitting data such as columns and records.
The head component x-table.thead
iterates through the columns:
@props(['columns'])
<thead>
@if(isset($columns) && is_array($columns))
@foreach ($columns as $column)
<x-table.th field="{{ $column }}" />
@endforeach
@endif
{{ $slot }}
</thead>
The head cell component x-table.th
generates the sort URL and displays the sort icon if needed :
@props(['field'])
@php
$sortField = request('sort_field');
$sortDir = request('sort_dir', 'asc') === 'asc' ? 'desc' : 'asc';
$sortIcon = fn($field) =>
$sortField === $field ? ($sortDir === 'asc' ? '↑' : '↓') : '';
$hxGetUrl = fn($field) =>
request()->fullUrlWithQuery([
'sort_field' => $field,
'sort_dir' => $sortDir
]);
@endphp
<th {{ $attributes->merge([
'class' => 'px-4 py-2 border text-left cursor-pointer',
'hx-get' => $hxGetUrl($field),
'hx-trigger' => 'click',
'hx-replace-url' => 'true',
'hx-swap' => 'outerHTML',
'hx-target' => '#table-container',
]) }}>
@if(isset($slot) && trim($slot) !== '')
{{ $slot }}
@else
<span>{{ Str::title($field) }}</span>
@endif
<span class="ml-1" role="img">{{ $sortIcon($field) }}</span>
</th>
And the body x-table.tbody
loops through the records:
@props(['columns', 'records'])
<tbody {{ $attributes->merge(['id' => 'table-body']) }}>
@if(isset($records))
@forelse ($records as $record)
<x-table.tr id="row-{{ $record->id }}">
@foreach($columns as $column)
<x-table.td>
@if($column === 'actions')
@if(isset($actions))
{{ $actions($record) }}
@else
<x-table.actions.view :record="$record"/>
<x-table.actions.edit :record="$record"/>
<x-table.actions.delete :record="$record"/>
@endif
@else
{{ $record->{$column} }}
@endif
</x-table.td>
@endforeach
</x-table.tr>
@empty
<x-table.tr>
<x-table.td colspan="100%">No record found.</x-table.td>
</x-table.tr>
@endforelse
@endif
{{ $slot }}
</tbody>
The individual cells x-table.td
handle displaying the data:
<td {{ $attributes->merge(['class' => 'px-4 py-2 border']) }}>
{{ $slot }}
</td>
And the row x-table.tr
provides a wrapper:
// Just for fun : time() - rand(100,2000)
<tr {{ $attributes->merge(['id' => "row-".(time() - rand(100,2000))]) }}>
{{ $slot }}
</tr>
Likewise, we can integrate table functions such as viewing, editing, and deleting. Make sure to explore the comprehensive guide for more details.
Using the Table Component
Now that we've broken down the table into reusable Blade components, let's explore how to apply them in our code. By utilizing these components effectively, we can ensure a more organized and streamlined approach.
Let's see the table component in action :
<div id="table-container"
hx-get="{{ route('contacts.index') }}"
hx-trigger="loadContacts from:body">
<x-table :records="$contacts"
:columns="['name', 'email', 'phone', 'address', 'actions']"/>
<div id="pagination-links" class="p-3"
hx-boost="true"
hx-target="#table-container">
{{ $contacts->links() }}
</div>
</div>
Final Remarks
In our exploration of combining HTMX with Laravel, we've achieved dynamic table sorting and smooth pagination. By creating reusable Blade components, we've simplified development and improved code organization.
For a more details, check out my blog post , and find the source code for this implementation on GitHub as well.
Make sure to follow me on Twitter to get notified when I publish more content.
driesvints, spiritkiddie, mshaf liked this article
Other articles you might like
Laravel Custom Query Builders Over Scopes
Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...
Access Laravel before and after running Pest tests
How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....
🍣 Sushi — Your Eloquent model driver for other data sources
In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...
The Laravel portal for problem solving, knowledge sharing and community building.
The community