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 extend the project to add client-side sorting and pagination by just using HTMX attributes, and no javascript at all.
Table of Contents
- Introduction
- A Short Revisit
- Pagination
- Sorting
- Refactoring with Blade Components
- Using the Table Component
- Final Remarks
Introduction
In my quest to build reactive web applications while upholding Laravel’s elegant MVC structure, I found most frameworks and libraries except HTMX require reorganizing your project in very specific ways that break Laravel’s conventions and separation of concerns.
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.
A Short Revisit
We have a typical folder structure for views like :
contacts
├── partials
│ ├── form.blade.php
│ └── table.blade.php
├── create.blade.php
├── edit.blade.php
├── index.blade.php
└── show.blade.php
The table.blade.php
partial which displays table of contacts
, looks something like this:
<table id="contacts-table" class="table-auto w-full">
<thead>
<th class="px-4 py-2 border text-left">Name</th>
<th class="px-4 py-2 border text-left">Email</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"
hx-get="{{ route('contacts.index') }}"
hx-trigger="loadContacts from:body">
@forelse ($contacts as $contact)
<tr id="contact-{{ $contact->id }}">
<td class="px-4 py-2 border">{{ $contact->name }}</td>
<td class="px-4 py-2 border">{{ $contact->email }}</td>
<td class="px-4 py-2 border">{{ $contact->phone }}</td>
<td class="px-4 py-2 border">{{ $contact->address }}</td>
<td class="px-4 py-2 border ">
<a class="mr-1 uppercase hover:underline"
hx-get="{{ route('contacts.show', $contact->id) }}"
hx-target="#section">View</a>
<a class="mr-1 uppercase hover:underline"
hx-get="{{ route('contacts.edit', $contact->id) }}"
hx-target="#section">Edit</a>
<a class="mr-1 uppercase hover:underline"
hx-delete="{{ route('contacts.destroy', $contact->id) }}"
hx-confirm="Are you sure you want to delete this contact?"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>Delete</a>
</td>
</tr>
@empty
<tr>
<td class="px-4 py-2" colspan="100%">No contacts found.</td>
</tr>
@endforelse
</tbody>
</table>
Pagination
While using {{ $contacts->links() }}
in blade view gives pagination links, but 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
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'));
}
...
...
}
Here you can see it in action,
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
TD, TR and TH
The x-table.td
component handles displaying the individual data cells in the table. It renders a HTML table cell <td>
tag and merges in a default class for styling. Inside the cell, it displays the content passed to the component via the $slot variable.
<td {{ $attributes->merge(['class' => 'px-4 py-2 border']) }}>
{{ $slot }}
</td>
The x-table.tr
component generates the overall table row. It renders a <tr>
tag and gives it a unique ID to identify the row.
// Just for fun : time() - rand(100,2000)
<tr {{ $attributes->merge(['id' => "row-".(time() - rand(100,2000))]) }}>
{{ $slot }}
</tr>
The x-table.th
component generates the header cells for the table. It takes in a field property which refers to the name of the column.
It first checks the current request parameters to determine the sorted field and direction. It defines some PHP functions to generate the sort icon if needed and build the URL for sorting by that field. When rendering the tag, it adds default classes for styling and several HTMX attributes:
- hx-get - The URL to request when clicked, passing the sort field/direction. This will reload the table sorted.
- hx-trigger - Specifies to trigger the AJAX request on click.
- hx-replace-url - Update the browser URL after the request.
- hx-swap - Replace the entire #table-container contents.
Inside the <th>
it displays the title of the field, and shows the sort icon if this is the currently sorted column.
@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>
So in summary, the x-table.th
component generates the header cells, handling building the sort URLs and displaying the sort indicator. This allows adding sort interactivity to the table headers with minimal code.
TABLE, THEAD and TBODY
The main x-table
component renders the <table>
tag itself. It takes the columns and records as inputs.
It first renders the table head using the x-table.thead
component, passing the columns. Then it renders the table body with x-table.tbody
, passing the columns and records.
@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>
The x-table.thead
component loops through the columns array and renders a x-table.th
header cell for each one.
@props(['columns'])
<thead>
@if(isset($columns) && is_array($columns))
@foreach ($columns as $column)
<x-table.th field="{{ $column }}" />
@endforeach
@endif
{{ $slot }}
</thead>
The x-table.tbody
component loops through the records and renders a row per record using the x-table.tr
component. Inside each row, it loops through the columns, rendering a x-table.td
cell per column.
For the “actions” column it renders the default view/edit/delete actions. For other columns it displays the cell data from the record object. If there are no records, it renders a single row displaying a “No records found” message.
@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')
<x-table.actions.view :record="$record"/>
<x-table.actions.edit :record="$record"/>
<x-table.actions.delete :record="$record"/>
@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>
So in summary:
- x-table renders the overall
<table>
- x-table.thead renders the head with headers
- x-table.tbody renders the body with the rows
- x-table.tr renders each row
- x-table.td renders each cell
This allows building a full table by composing reusable components.
Built in Actions
For common actions like view, edit, and delete, we can create reusable components to make them built-in actions:
The x-table.actions.view
component handles the view/show action. It generates a hyperlink with the provided attributes, allowing you to specify custom content for the link. If no content is specified, it defaults to “View”:
@props(['record'])
@php
$attrs = [
'class' => 'mr-1 uppercase hover:underline cursor-pointer',
'hx-target' => '#section'
];
if (isset($record)) {
$currentRouteName = request()->route()->getName();
$resourceName = explode('.', $currentRouteName)[0];
$attrs['hx-get'] = route($resourceName . '.show', $record->id);
}
@endphp
<a {{ $attributes->merge($attrs) }}>
@if(isset($slot) && trim($slot) !== '')
{{ $slot }}
@else
Show
@endif
</a>
The x-table.actions.edit
component handles the edit action. It generates a hyperlink with the specified attributes, allowing you to define custom content. If no content is provided, it defaults to “Edit”:
@props(['record'])
@php
$attrs = [
'class' => 'mr-1 uppercase hover:underline cursor-pointer',
'hx-target' => '#section'
];
if (isset($record)) {
$currentRouteName = request()->route()->getName();
$resourceName = explode('.', $currentRouteName)[0];
$attrs['hx-get'] = route($resourceName . '.edit', $record->id);
}
@endphp
<a {{ $attributes->merge($attrs) }}>
@if(isset($slot) && trim($slot) !== '')
{{ $slot }}
@else
Edit
@endif
</a>
Finally, the x-table.actions.delete
component handles the delete action. It generates a link with attributes, allows custom content, and adds a confirmation prompt before deletion. If no content is specified, it defaults to “Delete”:
@props(['record'])
@php
$attrs = ['class' => 'mr-1 uppercase hover:underline cursor-pointer'];
if (isset($record)) {
$currentRouteName = request()->route()->getName();
$resourceName = explode('.', $currentRouteName)[0];
$attrs['hx-delete'] = route($resourceName . '.destroy', $record->id);
$attrs['hx-confirm'] = 'Are you sure you want to delete this record?';
$attrs['hx-headers'] = json_encode(['X-CSRF-TOKEN' => csrf_token()]);
}
@endphp
<a {{ $attributes->merge($attrs) }}>
@if(isset($slot) && trim($slot) !== '')
{{ $slot }}
@else
Delete
@endif
</a>
Using the Table Component
After building the reusable table components, we can now use them to generate a full table in our application code.
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>
The example shows rendering a table of “contacts” data. It all sits inside a <div>
with the ID “table-container”. This outer <div>
has some HTMX attributes:
- hx-get - The route to request to populate the table initially.
- hx-trigger - Specifies to trigger the initial request on page load.
First, it renders the x-table component, passing:
- records - The Eloquent collection of contacts
- columns - The names of columns to display
Next, it renders a <div>
for the pagination links. This has:
- hx-boost - Keep this
<div>
in the DOM after updates to refresh pagination. - hx-target - Update the
#table-container
when paginating.
The pagination links come from the Laravel paginator helper. The components allow declaring the table markup in a clean, readable way.
Here is what we have as a result of all this,
Final Remarks
HTMX does not force you to write your code in certain way to get this interactivity out of the box. HTMX’s seamless integration empowers us to stay within the familiar Laravelish way of things, enabling us to maintain the MVC structure we’re accustomed to.
In our exploration of combining HTMX with Laravel, we’ve achieved dynamic table sorting and smooth pagination, enhancing the user experience. By creating reusable Blade components, we’ve simplified development and improved code organization.
You can find the complete source code for this implementation on GitHub: Project Link.
Make sure to follow me on Twitter to get notified when I publish more content.