Laravel + HTMX = ❤️ 3x
Tags: webdev, htmx, laravel, php
Published: 2023-10-14 09:51:52

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](https://laravel.com/docs/10.x/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 indexes](https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html)and 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 `VARCHAR`s 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.

```bash
php artisan make:migration add_full_text_index_on_chirps_message --table chirps
```

```php
<?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');  
        });  
    }  
};
```

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:

```html
<!-- 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

```

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:

```php
// 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;  
}
```

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:

```php
// 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,  
    ]);  
}
```

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.

```html
<!-- 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"  
>
```

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.

```php
// 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()),
            ]);
        }
```

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](https://htmx.org/docs/#oob_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.

```html
<!-- 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>

```

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

```html
<!-- 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>
```

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.

```php

/**  
 * 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()),  
        ]);  
    }  
}
```

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.

```html
<!-- 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
```

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:

```php
// app/Http/Controllers/ChirpController.php

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

```

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.

```html
<!-- 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?"
>
```

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:

```html
<!-- 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>
```

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

```php
// app/Http/Controllers/ChirpController.php

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

And we need to add it on the routes

```php
// 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']);
```

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.

```html

<!-- 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>
```

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:

```js
// 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' },  
        },  
    }  
},
```


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

```html

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

```

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

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