Back to the blog

Laravel + HTMX - Hard Mode - Part 1

Ideally, by the end of the article, you've learned some interesting ways to handle complicated user interface elements with Laravel and HTMX.

Since the technologies lean heavily into server side rendering, we will propose a reasonably complicated front-end interface where its state must be stored on the server.

The Problem

We need to show a table of products in our system. Due to a constraint that we are placing on ourselves, artificially, we must create two entirely different HTML structures on our page. One for mobile. One for desktop. Normally, these problems would be solved by having your element adjust itself based on breakpoints but, for the purpose of this exercise, we have no other way out.

We must have two different HTML structures for the same table and the same data.

We also want some basic filters for this data. Both HTML structures must contain these filters too.

This essentially means that if you paginate through the desktop version of the products table, the mobile products table should show up paginated where you left off if you, for example, magically switched to a mobile viewport or opened the same page on your mobile device. Any filters also need to be "transferred".

Already, we see where a server state might come in handy here.

What you'll need

I would recommend you use the latest version of Laravel, at least 10.x, but any version that contains blade fragments will do. We'll be using that a lot.

Include the latest version of HTMX and I would add Hyperscript. Even though I use Hyperscript in this solution, you could do similar things in Alpine or jQuery, for example.

Getting started and setup tutorials abound for all these things and I won't spend any time on that. We want to solve our problem and focus on that.

Getting Started

We need to setup a basic page in Laravel to render a simple view where we will house two tables.

We'll setup a basic controller that will load up a view for us. I won't go over every detail here since tutorials abound for doing these things. Here is a link to the official Laravel docs if you want a starting point: here

Let's assume that we have a route define like this:

Route::get('/complex_product_table', [ComplexProductTableController::class, 'show']);

Our ComplexProductTableController will just return our view, which we might name "complex_product_table". Our view will just load the whole page, head, html, body and all. We'll add some basic HTML structures and styles to get the ball rolling.

Hiding / Showing Mobile vs Desktop

Many tutorials abound for this too so I won't go over anything in too much detail. Essentially, you just want to make sure you have a breakpoint set in your CSS styles where you can hide or show the desktop and mobile versions of the form.

Note that we have IDs we can target directly but you can use whatever targeting method you like.

I personal like to use a small library called css-media-vars which lets you implement a whole bunch of cool stuff in pure CSS variables, including a bool logic system where you can choose to hide or display something based on the value of a css var.

Something like this, for example:

#complex_product_table_desktop {
    --display-lte-sm: var(--media-lte-sm) none;
    display: var(--display-lte-sm, block);

More or less, this lets you set a default value of "block", which shows the desktop mode, unless your breakpoint is currently at the sm or xs point, in which case the desktop form is hidden.

You can see where we're headed with this. Let's assume you have all that working.

The Table

Following is my half thrown together table structure for our product table.

I use inline styles just so it's easier to read the two different layouts. The desktop layout is more horizontal with the filters on the right hand side and the table on the left. The mobile flow is just stack rows. This design is, of course, very poor but we're just using it to make a point.

We are iterating over products and rendering them on each table row. You'll need to have a model in Laravel associated with that or even just fake out a collection of products. Either way, you can pass the products as a $product variable to the view. Tutorials abound on how to do these things.

We're also using fragments and separating out the mobile and the desktop portions into their own fragments.

HTMX + Hyperscript

You can see that our input fields aren't actually plugged into anything and we did that on purpose.

One immediate way out of this is to wrap the desktop and mobile versions in their own forms and use a bit of HTMX sprinkle to get them working.

One constraint we will add is that we're not allowed to use the form tag. No real reason for it other than to demonstrate that it's not *entirely* necessary if you're using HTMX.

You'll note a few changes made to the structure and maybe one or two features we got for free with HTMX.

The search input field doesn't need a button anymore. With Hyperscript, we attach an event listener for a rudimentary "keyup" event and just auto submit the form.

Speak of submitting, you'll see that the desktop and mobile wrapper have hx attributes that dictate how the data will be submitted, which fields to include and a trigger mechanism.

We also have hx-swap-oob set to true. When the "form" is submitted to the server, the server will return the updated HTML based on the filters applied and with hx-swap-oob set to true, HTMX will search the DOM for any elements that match the IDs of any element with hx-swap-oob. It will then automatically swap those elements.

You could attempt to submit the desktop and mobile ones individually and maybe add a hidden field on each one, like:

Then the server would return the fragment based on the mode it received but it would also return the opposite fragment with hx-swap-oob set to true. This would mean that the fragment with the mode you're actively using would just get swapped out fresh by HTMX and the hx-swap-oob would let HTMX know that it should also replace the other mode as well.

Another way to do this, however, is to set the swap and target to none on both elements and then return both fragments, no matter what, with hx-swap-oob set to true anyways.

You'll note that, in this example, we use GET requests which means all our values will be passed as query parameters in the URL. The nice thing about this is that you can re-create the state of your page this way by simple having Laravel listen to those query parameters and adjust the output of the view accordingly. I've hinted at this with the checkboxes.

We are using hx-get which means the requests and the query parameters will be passed under the hood. If we actually want this page to reflect the state of the form, we need to "float" these parameters to the parent page and URL. To do that, we can simply add hx-push-url="true" on both elements and you now have a synchronized state, front to back.

Note: hx-push-url might not be right depending on what you're doing to maintain state on the back-end. You might have to use hx-replace-url or even return response headers to update the URL accordingly. Your mileage will vary.

Even More Complicated

Let's set another restraint for ourselves. We cannot have both versions running separate requests. We must only have one element where all data is fed into and requests are submitted. Let's adjust our HTML structure a bit. Most stays the same but with some additions.

We've abstracted all the form data into a core form. It's hidden because you never actually interact with it. Instead, you interact with all the other elements which then feed their data into the core, hidden, form.

When the page initially loads, the server is in charge of ensuring the state of all the fields, forms, tables, etc...

Then you interact with the filters which triggers a synchronization of the action you took into the hidden form. The hidden form submits itself to the server with the updated form data. The hidden form is swapped but your server should also return the mobile and desktop views too. Those fragments should get hx-swap-oob set to true by default because they are always going to be replaced along with the hidden from swap, which HTMX handles automatically.

Your server remains the consistent source of truth.

Final Comments

I've skipped several details here because I feel like there are plenty of resources available to solve a variety of issues.

For example, the keyup event on the input field might need some throttling.

You can see I've already implement a hyperscript alternative to check if a checkbox is actually checked. Simply toggle a class and match it. Your server would have to ensure the class is applied if the value is truthy on that checkbox.

In Laravel, you can separate out all these fragments into their own components and coalesce them when you render the full view on initial page load. Subsequent loads, upon request, only need to return the fragments.

Since you're pushing the state of the URL into the browser history, anybody who copies the link to this page should end up in the same state as anybody else with the same link.