Support the ongoing development of Laravel.io →
Article Hero Image

Table Sorting and Pagination with HTMX in Laravel

27 Oct, 2023 7 min read

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.

Last updated 3 weeks ago.

driesvints, spiritkiddie, mshaf liked this article

3
Like this article? Let the author know and give them a clap!
mshaf (Muhammad Shafeeq) Writing at muhammadshafeeq.com

Other articles you might like

Article Hero Image November 18th 2024

Laravel Custom Query Builders Over Scopes

Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...

Read article
Article Hero Image November 19th 2024

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....

Read article
Article Hero Image November 11th 2024

🍣 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...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.