DEV Community

Cover image for Laravel + HTMX = ❤️ 3x
Turcu Laurentiu
Turcu Laurentiu

Posted on

Laravel + HTMX = ❤️ 3x

Welcome to the final installment of our Chirper tutorial series. In this part, we will refine our platform by introducing a few advanced features: an active search mechanism, more robust form validation, and a user-friendly confirmation dialog for chirp deletion. These enhancements are aimed at not only improving the user experience but also showcasing the flexibility and power of combining Laravel with HTMX and Hyperscript. Let's delve in and give Chirper a polished edge!

Searching trough chirps

On a more complex app you might want a different search engine/database and you might want to integrate with Laravel Scout and Angolia. However, for the purpose of this tutorial, a simple LIKE query would have been fine, if I didn't generated a huge amount of chirps on my test server.

In order to gain some performance and keep my test server simple and cheap, I will go with MySQL FULLTEXT indexesand raw queries. It's a good enough search mechanism that you can follow if you have MySQL. If you are using anything else, replace the scope with a LIKE and it will work just fine.

The full text index

MySQL offers a full text index to be applied on VARCHARs and TEXT fields that you can later use to perform natural language search in your tables, let's create a migration to apply the index.

php artisan make:migration add_full_text_index_on_chirps_message --table chirps
Enter fullscreen mode Exit fullscreen mode
<?php  

use Illuminate\Database\Migrations\Migration;  
use Illuminate\Database\Schema\Blueprint;  
use Illuminate\Support\Facades\Schema;  

return new class extends Migration  
{  
    /**  
     * Run the migrations.     
     **/    
     public function up(): void  
    {  
        Schema::table('chirps', function (Blueprint $table) {  
            \Illuminate\Support\Facades\DB::statement('ALTER TABLE chirps ADD FULLTEXT fulltext_index (message)');  
        });  
    }  

    /**  
     * Reverse the migrations.     
     **/    
     public function down(): void  
    {  
        Schema::table('chirps', function (Blueprint $table) {  
            \Illuminate\Support\Facades\DB::statement('ALTER TABLE chirps DROP INDEX fulltext_index');  
        });  
    }  
};
Enter fullscreen mode Exit fullscreen mode

Cool, now apply it with php artisan migrate, this might take a couple of seconds, depending on how many chirps you have.

Legacy form submit search

Let's start by adding a normal HTML form that will perform the search action, and later we will enhance it with HTMX to search while you type. In the chirps.index template, we will add the form:

<!-- resources/views/chirps/index.blade.php -->

<x-app-layout>  
    <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">  

        <form            
            method="GET"  
            action="{{ route('chirps.index') }}"  
            class="flex items-center space-x-2 mb-4 w-full bg-white border-1 border-gray-300 px-4 py-2 rounded-md shadow-sm focus-within:ring focus-within:border-indigo-200 focus-within:ring-indigo-200 focus-within:ring-opacity-50"  

        >  
            <input                
            type="text"  
                name="search"  
                aria-label="search"  
                class="w-full bg-white border-0 p-0 focus:ring-0 focus:border-0 focus:outline-none"  
                placeholder="Search for chirps"  
                value="{{ request('search') }}"  
            >  
            <button                
                type="submit"  
                class="bg-gray-100 p-1 rounded hover:bg-gray-400 transition"  
            >  
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">  
                    <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />  
                </svg>            
            </button>        
        </form>
    <!-- rest of the template -->

    <!-- long pooling div -->
    @if($chirps->count() > 0)  
        <div hx-get="{{  
                route('chirps.pool', [  
                    'latest_from' => $chirps->first()->created_at->toISOString(),  
                    'search' => request('search')]) }}"  
             hx-trigger="every 2s"  
             hx-swap="outerHTML"></div>  
    @endif

Enter fullscreen mode Exit fullscreen mode

This is a simple HTML form, that will make a GET request to the same index page with the ?search query param. We also modified the long pooling <div>, we firstly wrapped it in an @if to render it only if there are chirps (to avoid an error when there are no chirps) and we also added the search query param to the route function, to avoid loading chirps that don't satisfy the search query.

Now let's add a search scope in the chirps model:

// app/Models/Chirp.php

public function scopeSearch(Builder $query, mixed $search): Builder  
{  
    if(is_string($search)) {  
        $search = Str::of($search)->trim()  
            ->explode(' ')  
            ->map(fn($word) => '+' . $word . '*')  
            ->join(' ');  

        return $query->whereRaw('MATCH(message) AGAINST(? IN BOOLEAN MODE)', [$search])  
            ->orderByRaw('MATCH(message) AGAINST(? IN BOOLEAN MODE) DESC', [$search]);  
    }  

    return $query;  
}
Enter fullscreen mode Exit fullscreen mode

What this scope is doing, if the $search param is a string, it will explode it into words, prepend a + and append an * to each word. The + means that the message must include the word and the * means that the matched word must start with.

The order by will order by relevance.

And we can use the scope in the index and pool controller methods:

// app/Http/Controllers/ChirpController.php

/**  
 * Display a listing of the resource. 
 **/
 public function index(Request $request): Response  
{  

    $chirps = Chirp::with('user')  
        ->latest()  
        ->when($request->has('search'), fn($query) => $query->search($request->query('search')))  
        ->paginate(25);  

    return \response()->view('chirps.index', [  
        'chirps' => $chirps  
    ]);  
}  

public function pool(Request $request): Response  
{  
    $validated = $request->validate([  
        'latest_from' => 'required|date',  
    ]);  

    $chirps = Chirp::with('user')  
        ->where('created_at', '>', $validated['latest_from'])  
        ->where('user_id', '!=', $request->user()->id)  
        ->when($request->has('search'), fn($query) => $query->search($request->query('search')))  
        ->latest()  
        ->get();  

    if($chirps->count() === 0) {  
        return \response()->noContent();  
    }  

    return \response()->view('chirps.pool', [  
        'chirps' => $chirps,  
    ]);  
}
Enter fullscreen mode Exit fullscreen mode

We only added the when statements, to use the search query scope only when the search query param is present on the request.

Active search

This is cool and all and it works just fine, but there is no HTMX magic here excluding the boosting. So let's add some HTMX properties on the search input to search when the user finished typing.

<!-- resources/views/chirps/index.blade.php -->

<input  
    type="text"  
    name="search"  
    aria-label="search"  
    class="w-full bg-white border-0 p-0 focus:ring-0 focus:border-0 focus:outline-none"  
    placeholder="Search for chirps"  
    value="{{ request('search') }}"  
    hx-trigger="keyup changed delay:500ms"  
    hx-get="{{ route('chirps.index') }}"  
    hx-target="#chirps"  
    hx-swap="outerHTML"  
    hx-select="#chirps"  
>
Enter fullscreen mode Exit fullscreen mode

That's all we need to add for the active search. What we instruct HTMX is:

  • hx-trigger: when the keyup event fires on this input, if the value changed we delay for 500ms and if no new event happens we do the request
  • hx-get: perform a get request to the index, it will add the search query param using the name of the field to decide how to name it.
  • hx-target hx-swap and hx-select: it will select the <div id="#chirps" from the response and it will replace the whole div for the target with the same ID.

Form Validation

So far, across our app, we just ignored form validation, in this section we will take a look at how to display back-end validation messages using HTMX.

Edit Chirp Form

When editing the chirp in the ChirpController::update, if the validation fails, the response throws an ValidationError and Laravel's default behavior in a multi page app is to redirect back with 302 and make the validation $errors available for the template.

We like that for the cases when JS is disabled, however, when making requests with HTMX we want to modify this.

// app/Http/Controllers/ChirpController.php

/**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Chirp $chirp): RedirectResponse|Response
    {
        $this->authorize('update', $chirp);

        try {
            $validated = $request->validate([
                'message' => 'required|string|max:255',
            ]);

            $chirp->update($validated);

            if($request->header('HX-Request')) {
                return response()->view('components.chirps.single', [
                    'chirp' => $chirp,
                ]);
            }

            return redirect(route('chirps.index'));
        } catch (ValidationException $e) {
            if(!$request->header('HX-Request')) {
                throw $e;
            }

            return response()->view('components.chirps.edit', [
                'chirp' => $chirp,
                'errors' => collect($e->errors()),
            ]);
        }
Enter fullscreen mode Exit fullscreen mode

What we did here is we wrapped everything in a try/catch statement and caught the ValidationException. Then, if this is not an HTMX request, we throw it back and leave Laravel to handle it (no JS case).

If it's an HTMX request, we return the edit form, and we make the $e->errors() available to the template in a collection. The HTMX behavior if this form is to replace itself with the HTML from the response.

Give it a try!

The Create Chirp Form

The create chirp form is a bit more complicated because we target the #chirps div with the response instead of itself. For this, we will take advantage of the out of band swaps (OOB) in HTMX.

This means that those elements marked with hx-swap-oob="true" won't follow the normal hx-swap but it will match the elements by ID and swap them. There is one rule, the OOB elements in the response must be at the root level, but this works just fine for our case.

Let's start by extracting the create chirp form into a component and add and ID and the OOB property.

<!-- resources/views/components/chirps/create.blade.php -->

@props(['oob' => true])

<form
    id="chirps-create"
    @if($oob)
        hx-swap-oob="true"
    @endif
    method="POST"
    action="{{ route('chirps.store') }}"
    hx-post="{{ route('chirps.store') }}"
    hx-target="#chirps"
    hx-swap="afterbegin"
    hx-on="htmx:afterRequest: if(event.detail.successful) this.reset();"
>
    @csrf
    <textarea
        name="message"
        placeholder="{{ __('What\'s on your mind?') }}"
        class="block w-full border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 rounded-md shadow-sm"
    >{{ old('message') }}</textarea>
    <x-input-error :messages="$errors->get('message')" class="mt-2" />
    <x-primary-button class="mt-4">{{ __('Chirp') }}</x-primary-button>
</form>

Enter fullscreen mode Exit fullscreen mode

Replace the old form in the index template with this new component

<!-- resources/views/chirps/index.blade.php -->

<!-- Just below the search form, replace the old create form with the new component -->

<x-chirps.create :oob="false"></x-chirps.create>
Enter fullscreen mode Exit fullscreen mode

Now let's do the same with the store method on the chirps controller, let's wrap it with a try/catch and if we have HTMX request and we have errors, we render the form component only.


/**  
 * Store a newly created resource in storage. 
 **/
 public function store(Request $request): RedirectResponse|Response  
{  
    try {  
        $validated = $request->validate([  
            'message' => 'required|string|max:255',  
        ]);  

        $chirp = $request->user()->chirps()->create($validated);  

        if ($request->header('HX-Request')) {  
            return response()->view('components.chirps.single', [  
                'chirp' => $chirp,  
            ]);  
        }  

        return redirect()->route('chirps.index');  
    } catch (ValidationException $e) {  
        if (!$request->header('HX-Request')) {  
            throw $e;  
        }  

        return response()->view('components.chirps.create', [  
            'errors' => collect($e->errors()),  
        ]);  
    }  
}
Enter fullscreen mode Exit fullscreen mode

And that's all, now if you try to create a new chirp without a message, you will see the validation error.

However, there is a problem. If you successfully create a new chirp while having a validation error, the validation error persists, let's fix that!

We can add the form in the single chirp template, but we don't need to add it always, but only when we want.

<!-- resources/views/components/chirps/single.blade.php -->

<!-- At the top of the template -->

@props(['chirp', 'withCreateForm' => false])

@if($withCreateForm)
    <x-chirps.create></x-chirps.create>
@endif
Enter fullscreen mode Exit fullscreen mode

The $withCreateForm prop, we can control if we want to also render the create form, usually we don't so we set false as a default.

In the store method on the chirp controller, if we are successful, we return the single chirp template with the create form:

// app/Http/Controllers/ChirpController.php

if ($request->header('HX-Request')) {
    return response()->view('components.chirps.single', [
        'chirp' => $chirp,
        'withCreateForm' => true,
    ]);
}

Enter fullscreen mode Exit fullscreen mode

And on the create form, we can drop the hx-on script, because now we reset it by replacing the whole <div>

Confirmation Dialog

On our current implementation, when you want to delete a chirp, you just delete it. To make it safer for the users, we can add a confirmation dialog. HTMX comes with a hx-confirm attribute that takes advantage of the browser confirmation dialogs.

<!-- resources/views/components/chirps/single.blade.php -->

 <form method="POST"
      action="{{ route('chirps.destroy', $chirp) }}"
      hx-delete="{{route('chirps.destroy', $chirp)}}"
      hx-target="closest .chirp"
      hx-swap="delete"
      hx-confirm="Are you sure you want to delete this chirp?"
>
Enter fullscreen mode Exit fullscreen mode

Now when you delete a chirp the browser asks you if you are sure about this before deleting. However, this is not the best user experience possible, we want to render HTML modals.

To do so, we will need to first create a new component:

<!-- resources/views/components/chirps/confirm-destroy.blade.php -->

@props(['chirp'])  

<div class="modal fixed z-10 inset-0 overflow-y-auto flex justify-center items-center bg-black bg-opacity-50" style="backdrop-filter: blur(14px);">  
    <div class="bg-white rounded p-6">  
        <h2 class="text-xl border-b pb-2 mb-2">Confirm Action</h2>  
        <p>Are you sure you want to delete this chirp?</p>  
        <div class="flex justify-end mt-4 gap-4">  
            <x-secondary-button _="on click remove closest .modal" >
                Cancel
            </x-secondary-button>  
            <form>                
                @csrf  
                <x-danger-button  
                    hx-delete="{{route('chirps.destroy', $chirp)}}"  
                    hx-target="closest .chirp"  
                    hx-swap="delete">  
                        Delete  
                </x-danger-button>  
            </form>
        </div>    
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This will render a dialog modal with a title and some content and two buttons.

  • The cancel button will just delete the closest .dialog from the DOM using Hyperscript
  • The second will make a delete request, and it will delete the closest .chirp from the DOM on success. Note that we had to wrap it in a <form> to include the @csrf blade attribute to render the CSRF hidden input.

Now we need a new method on the chirps controller to render this dialog

// app/Http/Controllers/ChirpController.php

public function confirmDestroy(Chirp $chirp): Response
{
    return response()->view('components.chirps.confirm-destroy', [
        'chirp' => $chirp,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

And we need to add it on the routes

// routes/web.php

Route::get('chirps/pool', [ChirpController::class, 'pool'])->name('chirps.pool');
Route::get('chirps/{chirp}/confirm-destroy', [ChirpController::class, 'confirmDestroy'])
    ->name('chirps.confirm-destroy');
Route::resource('chirps', ChirpController::class)
    ->only(['index', 'show', 'store', 'edit', 'update', 'destroy'])
    ->middleware(['auth', 'verified']);
Enter fullscreen mode Exit fullscreen mode

And we need to modify the single chirp template, that when we click on the delete button, it will get the dialog and insert it in the DOM rather than deleting the chirp.


<!-- resources/views/components/chirps/single.blade.php -->

<form method="POST"  
      action="{{ route('chirps.destroy', $chirp) }}"  
      hx-delete="{{route('chirps.destroy', $chirp)}}"  
      hx-target="closest .chirp"  
      hx-swap="delete"  
>  
    @csrf  
    @method('delete')  
    <x-dropdown-link  
        :component="'button'"  
        type="submit"  
        hx-get="{{ route('chirps.confirm-destroy', $chirp) }}"  
        hx-swap="beforeend"  
        hx-target="closest .chirp"  
    >  
        {{ __('Delete') }}  
    </x-dropdown-link>  
</form>
Enter fullscreen mode Exit fullscreen mode

We also deleted the hx-confirm attribute on the form. We kept the old delete form here to remain usable when the JS is disabled.

The only missing piece is some animations to make the user experience better, so let's define two custom animations in tailwind:

// tailwind.config.js

extend: {  
    fontFamily: {  
        sans: ['Figtree', ...defaultTheme.fontFamily.sans],  
    },  
    animation: {  
        'fade-in': 'fade-in 0.2s ease-out',  
        'fade-out': 'fade-out 0.2s ease-out',  
    },  
    keyframes: {  
        'fade-in': {  
            '0%': { opacity: '0' },  
            '100%': { opacity: '1' },  
        },  
        'fade-out': {  
            '0%': { opacity: '1' },  
            '100%': { opacity: '0' },  
        },  
    }  
},
Enter fullscreen mode Exit fullscreen mode

And modify the confirm-destroy template to make use of Hyperscript to animate the modal:


<!-- resources/views/components/chirps/confirm-destroy.blade.php -->

@props(['chirp'])

@props(['chirp'])  

<div  
    _="on closeModal(destroy)  
            add .animate-fade-out            
            then wait for animationend            
            then if destroy remove closest .chirp else remove me        
        end        
            on click if (event.target == event.currentTarget) trigger closeModal"    
        class="modal fixed z-10 inset-0 overflow-y-auto flex justify-center items-center bg-black bg-opacity-50 animate-fade-in"  
        style="backdrop-filter: blur(14px);">  
    <div class="bg-white rounded p-6">  
        <h2 class="text-xl border-b pb-2 mb-2">Confirm Action</h2>  
        <p>Are you sure you want to delete this chirp?</p>  
        <div class="flex justify-end mt-4 gap-4">  
            <x-secondary-button _="on click trigger closeModal" >Cancel</x-secondary-button>  
            <form>                @csrf  
                <x-danger-button  
                    hx-delete="{{route('chirps.destroy', $chirp)}}"  
                    hx-target="closest .chirp"  
                    hx-swap="none"  
                    _="on htmx:afterRequest trigger closeModal(destroy:true)"  
                    >  
                        Delete  
                </x-danger-button>  
            </form>        
        </div>    
    </div>
</div>

Enter fullscreen mode Exit fullscreen mode

First of all, on the parent div, we added the .animate-fade-in class that will animate when inserted into dom.

Clicking on the buttons, when we click on cancel button a custom closeModal event is dispatched. Also, we changed the hx-swap to none when deleting, and translated the htmx:afterRequest into a closeModal with destroy set to true.

We also added quite some chunk of Hyperscript on the parent div:

on closeModal(destroy)  
    add .animate-fade-out            
    then wait for animationend            
    then if destroy remove closest .chirp else remove me        
end        
    on click if (event.target == event.currentTarget) trigger closeModal
Enter fullscreen mode Exit fullscreen mode

This instructs Hyperscript to listen for closeModal events and:

  • Add the animate-fade-out class to animate it out
  • wait for the animationend event, or in English, wait for animation to end
  • if destroy is truthful, then we delete the parent chirp, otherwise we delete only the dialog

The second part of the script instructs Hyperscript to trigger a closeModal on itself if the target is the currentTarget, thus closing the modal when clicking on the background.

Thus adding some nice fade in and out animations for the dialog.

The conclusion

Over the course of these tutorials, we've journeyed through the process of creating a dynamic, real-time application using a blend of Laravel, HTMX, and Hyperscript, culminating in a functional Chirper platform. These technologies have facilitated a modern, interactive user experience without the overhead typically associated with heavy front-end frameworks.

In part one, we laid the groundwork by setting up the Laravel application, defining the necessary routes, controllers, and views. The Chirper platform began to take shape as we created a simple interface for users to post chirps.

Part two saw the introduction of real-time functionality through HTMX, allowing users to see new chirps as they are posted by others, without needing to refresh the page. This was a key step in enhancing the user interaction and making the platform more engaging.

In the final part, we delved deeper into enhancing user experience by implementing a full-text search functionality, refining form validation, and introducing a user-friendly confirmation dialog for chirp deletion. These enhancements were aimed at making the platform more robust, user-friendly, and ready for real-world use.

The live version of the Chirper project can be explored at https://chirper.tlaurentiu.net. For those interested in the code behind the platform, the entire project is available on GitHub: https://github.com/turculaurentiu91/chirper-htmx. This project serves as a practical example of how modern web technologies can be leveraged to build interactive, real-time applications with a minimal front-end footprint.

This tutorial series demonstrates not only the technical steps required to build such a platform but also the thought process and decision-making involved in choosing technologies and implementing features. It's a glimpse into the possibilities that open up when blending server-side frameworks like Laravel with client-side libraries like HTMX and Hyperscript.

We hope this series has been informative and inspiring, encouraging you to explore these technologies and perhaps integrate them into your future projects.

Top comments (0)