DEV Community

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

Posted on • Updated on

Laravel + HTMX = ❤️ 2x

In this exciting second installment of our tutorial series, we will venture further into the realm of modern web application development with Laravel and HTMX. Our main focus will be on enriching the user experience on the index page by introducing real-time updates for new chirps. Initially, we'll employ a polling strategy to fetch fresh chirps at regular intervals. As we progress, we'll transition from polling to a more dynamic and efficient approach using Web-Sockets, ensuring that new chirps are displayed to users as they are posted, in real time.

If you didn't already, checkout the first part of this tutorial. You can also checkout the full implementation on this GitHub repository.

Real-Time Updates: The Polling Edition

So far, our simple HTMX implementation has been working smoothly, and users are chirping away happily. However, the product owner has pointed out a less than ideal user experience: to view new chirps, users need to manually refresh the page. In response to this feedback, we'll be implementing a polling mechanism using HTMX to fetch new chirps from the server periodically.

Let's begin by creating a new controller action to handle the polling request:


// app/Http/Controllers/ChirpController.php

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)  
        ->latest()  
        ->get();  

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

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

In this action:

  1. We validate the input to ensure latest_from is a properly formatted date.
  2. We query the database for chirps that are newer than the latest_from date, excluding chirps from the current user.
  3. If no new chirps are found, we return a 204 No Content response. Otherwise, we render the chirps.pool template.

Now, let's adjust the chirps.index template to include a new div responsible for polling new chirps from the server:


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

<div x-data="{noscriptFix: true}" id="chirps" class="mt-6 bg-white shadow-sm rounded-lg divide-y">  
    <div hx-get="{{ route('chirps.pool', ['latest_from' => $chirps->first()->created_at->toISOString()]) }}"  
         hx-trigger="every 2s"  
         hx-swap="outerHTML"></div>

    @foreach ($chirps as $chirp)  
        <x-chirps.single :chirp="$chirp" />  
    @endforeach  

    @if($chirps->nextPageUrl())  
        <div  
            hx-get="{{ $chirps->nextPageUrl() }}"  
            hx-select="#chirps>div.chirp,#chirps>div.chirps-paginator"  
            hx-swap="outerHTML"  
            hx-trigger="intersect"  
            x-cloak  
            x-if="noscriptFix"  
            class="chirps-paginator"  
>  
            Loading more...  
        </div>  
    @endif
Enter fullscreen mode Exit fullscreen mode

We also updated the way paginator work, to select only the chirps and the .chirps-paginator div so we don't include the pooling element twice. We only need one pooling div, at the top of the list.

Additionally, we'll need to create a new template, chirps.pool, which will return the new chirps along with a self-replacing div to continue the polling:


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

<div hx-get="{{ route('chirps.pool', ['latest_from' => $chirps->first()->created_at->toISOString()]) }}"  
     hx-trigger="every 2s"  
     hx-swap="outerHTML"></div>  

@foreach($chirps as $chirp)  
    <x-chirps.single :chirp="$chirp" />  
@endforeach
Enter fullscreen mode Exit fullscreen mode

Note the duplicated div that pools, you can abstract in a component if you wish to be DRY

In essence, we instruct HTMX to send a GET request every two seconds to the chirps.pool route. If new chirps are found, HTMX will replace the entire content of the polling div with the new chirps and a fresh polling div, ensuring that the polling continues with the updated latest_from date.

This mechanism efficiently delivers real-time updates to our users without requiring a page refresh, significantly enhancing the user experience on our platform.

Real-Time Updates: Introducing Web Sockets

For simple updates like this pooling might be just enough, and you can optimize a bit further by increasing the pool time to five seconds or more. But let's pretend that some users lamented that with the new update, the website it's consuming their data plan and it's draining their battery, so our PO comes around and asks us for a more optimal solution.

Broadcasting events with Laravel

Laravel has a nice an easy way to broadcast events via web-sockets. The only problem in using it with HTMX is that you communicate with JSON rather than with HTML, and thus we are stranding away from the concept of HATEOAS (Hypermedia as the Engine of Application State).

We can make it work with Laravel events and HTMX web-sockets extension by rendering the HTML string in some field in the event JSON on the back-end, and on the front-end we can register an event listener for htmx:wsBeforeMessage and transform the event in such a way that event.detail.message contains only the HTML string.

But this is complicated, and if I like something, I like simple and straightforward approaches. So what we are going to do is:

  1. We are going to use Laravel ECHO to receive the web-socket broadcast events
  2. When we receive the events, we translate in a custom browser event chirper:newChirp
  3. We fetch new chirps via HTMX when the event fires, preferably debounced

I will use soketi for this, but you can use whatever you want. I will not include instructions on how to setup Laravel, you can follow the official documentation

We will reuse the chirps/pool endpoint and template, we will have to modify the template a little bit


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

<div  
    hx-trigger="chirper:chirpCreated throttle:500 from:body"  
    hx-get="{{ route('chirps.pool', ['latest_from' => $chirps->first()->created_at->toISOString()]) }}"  
    hx-swap="outerHTML"  
    ></div>  

@foreach($chirps as $chirp)  
    <x-chirps.single :chirp="$chirp" />  
@endforeach
Enter fullscreen mode Exit fullscreen mode

We basically changed the trigger from every 2s to chirper:chirpCreated throttle:500 from:body, meaning that every type there is an chirp-created event triggered on body, throttle for 500ms and then get the data.

Make sure you do the same change for chirps/index.blade.php

In the resources/js/app.js ( make sure you have Echo configured according to your setup)

    laravelEcho.channel('chirps')  
    .listen('ChirpCreated', (event) => {  
        document.body.dispatchEvent(new CustomEvent('chirper:chirpCreated'));  
    }  
);
Enter fullscreen mode Exit fullscreen mode

This bit of JS will convert the ChirpCreated event from the chirps channel to a CustomEvent on the body of the document.

And in the PHP part of the app, we need to make the ChirpCreated event to implement ShouldBroadcast and specify the channel it should broadcast, namely chirps

<?php  

namespace App\Events;  

use App\Models\Chirp;  
use Illuminate\Broadcasting\Channel;  
use Illuminate\Broadcasting\InteractsWithSockets;  
use Illuminate\Broadcasting\PresenceChannel;  
use Illuminate\Broadcasting\PrivateChannel;  
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;  
use Illuminate\Foundation\Events\Dispatchable;  
use Illuminate\Queue\SerializesModels;  

class ChirpCreated implements ShouldBroadcast  
{  
    use Dispatchable, InteractsWithSockets, SerializesModels;  

    /**  
     * Create a new event instance.     
     * */    
     public function __construct(public Chirp $chirp)  
    {  
    }  

    /**  
     * Get the channels the event should broadcast on.     
     *     
     * @return array<int, Channel>  
     */    
     public function broadcastOn(): array  
    {  
        return [  
            new Channel('chirps'),  
        ];  
    }  
}
Enter fullscreen mode Exit fullscreen mode

And that should be it. Now we only fetch the chirps when a new chirp is created. You can have a more advanced solution where you broadcast to private channels and broadcast toOthers but it's just fine for the scope of this tutorial.

P.S. I will not deploy the web-sockets on my test server, it's a hustle to deploy soketi and to setup nginx and such, so it will just showcase the pooling version. No For the next sections, we will start from the end of the pooling section.

Scripting

Now, with real-time updates, the page refreshes itself with new chirps from the server. However, a challenge arises when a user scrolls down to read older chirps—they won't be aware of new chirps inserted at the top of the page.

A solution is to introduce a floating button whenever new chirps are inserted. This button, when clicked, will scroll the page to the top, making the user aware of the new chirps.

Scripting is essential for implementing this feature. While HTMX provides the hx-on attribute which could be sufficient for simpler tasks like resetting a form, our scenario requires an intersection observer to remove the button when the user scrolls to the top—either manually or by clicking the button.

Here's where Hyperscript comes into play. Hyperscript is designed to simplify event-based scripting in the browser, making it more human-readable. Although we won't delve deeply into its syntax, feel free to explore it on their website.

Next, we'll walk through the steps to integrate Hyperscript and implement the floating button feature to enhance user awareness of new chirps.

We need to install the script, so in the app layout, after the HTMX script, we add another script:

<!-- resources/views/layouts/app.blade.php -->

<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni" crossorigin="anonymous"></script>  
<script src="https://unpkg.com/hyperscript.org@0.9.11"></script>
Enter fullscreen mode Exit fullscreen mode

In the chirps pool template we add the following HTML, above the new chirps foreach

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

<div 
     class="border-transparent opacity-0"   
    _="on intersection(intersecting) if intersecting remove me else remove .opacity-0" >
    <div class="fixed top-4 left-0 right-0 flex justify-center">  
        <button            
            _="on click window.scrollTo({ top: 0, behavior: 'smooth' })"  
            type="button"  
            class="py-2 px-4 bg-indigo-600 hover:bg-indigo-800 rounded-full text-white shadow-lg flex justify-center items-center gap-4"  
        >  
            <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="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75" />  
            </svg>  
            Bew Chirps  
        </button>  
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This peace of HTML consists in two wrapper <div> elements, one to position the element in the list and another one to fix the button position at the top of the page.

The parent <div> element has an interesting attribute, _="on intersection(intersecting) if intersecting remove me else remove .opacity-0". This will instruct Hyperscript to install an intersection observer for this div, that on intersection, if is intersecting, to remove the element otherwise remove the opacity-0 class. So when the user scrolls to the top ether naturally or by clicking on the button, it will disappear.

The opacity is to avoid the button flickering on the screen when the user is already at the top of the page.

On the <button> element, we also have an Hyperscript attribute _="on click window.scrollTo({ top: 0, behavior: 'smooth' })" that will scroll the page to the top. It's the same as onclick="window.scrollTo({ top: 0, behavior: 'smooth' })"

You can do more advanced scripting with Hyperscript, you can insert classes, you can wait for animations, etc. etc.

As a homework, I encourage you to look at the documentation, and insert a slide down animation when the button is inserted and add a slide-up animation before the button is removed.

And that's all, now when the pooling gets new chirps, it will also insert the button and inform the user about the button presence.

Conclusion

We've come a long way in transforming Chirper from a simple Blade project to a modern web app with real-time updates and a user-friendly notification system. Leveraging HTMX and Hyperscript, we were able to enhance our application with infinite scrolling and web sockets, all while keeping the implementation straightforward and readable. Our journey highlights the power and simplicity these libraries bring to the table, enabling us to build dynamic features with less complexity compared to heavier frameworks.

Our exploration doesn't end here. We've merely scratched the surface of what's possible with HTMX, Hyperscript, and Laravel. As we move forward, we'll continue to refine our application, ensuring it not only meets our users' needs but also provides a delightful user experience.

What's Next?

In the upcoming third part of this series, we'll dive into implementing an active search functionality to help users easily find specific chirps. We'll also focus on enhancing the robustness of our application by utilizing Laravel's validation features to display form errors, ensuring a smooth and informative user interaction. Furthermore, we'll add a layer of confirmation dialogs for deleting chirps, providing a safer user experience and minimizing accidental data loss.

Through these enhancements, we aim to make Chirper a more interactive, user-friendly, and robust platform. So, stay tuned for more insights and practical implementations in the next installment of our journey with Chirper!

In the meantime, feel free to explore and experiment with the code we've built so far. There's a lot to learn and a myriad of ways to further refine and expand upon what we've created. Until next time, happy coding!

Top comments (0)