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.
Other articles you might like
Laravel 12 Custom Validation Rules Example
In this Laravel tutorial titled “laravel 12 custom validation rules example”, you will learn how to...
Returning HTTP 404 Responses Instead of 403 for Unauthorised Access
Introduction When building a web application, you typically add authorisation checks to ensure that...
Run PHPUnit and Pest Tests Without Vite Assets in Laravel
Introduction A common way to build your Laravel application's frontend assets is with Vite (by runni...
The Laravel portal for problem solving, knowledge sharing and community building.