Skip to content
Posted on:October 25, 2023 | 10 min read

Table Sorting and Pagination with HTMX in Laravel

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

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.

Pagination

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,

Sorting

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:

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:

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:

First, it renders the x-table component, passing:

Next, it renders a <div> for the pagination links. This has:

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, Pagination and Sorting

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.