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 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.
php artisan make:migration add_full_text_index_on_chirps_message --table chirps
<?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:
<!-- 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:
// 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:
// 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.
<!-- 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
andhx-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()),
]);
}
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>
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>
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()),
]);
}
}
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
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,
]);
}
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?"
>
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>
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,
]);
}
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']);
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>
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' },
},
}
},
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>
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. 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)