A breath of HTMX
Apr 11 2025
htmx
php
Lightening up an old PHP project with modern sprinkles.
I recently had the task of updating an internal php project, nothing fancy just a few simple forms and table views. This site had been around for a bit and I hadn’t gotten around to doing upgrades because it got the job done. Now that I had the chance to look it over I thought now was a good time to do some much needed project refactoring and UI/UX polish.
The current state of the project was what you’d find in an 2003 PHP code base, there boilerplate for database queries and auth checks — with no real separation between views and logic. I now had a list of things to clean up:
- Use partials + layouts
- Add progressive enhancements without JS fatigue
- Clean up request handling, add validation and feedback
- Extract logic into handlers and routes
- Move hard coded environment values to a dot env file.
The project was small enough that I didn’t feel the need to reach for a heavy hitter like Laravel. I knew I wanted to leave the codebase better than I found it — and part of that meant improving the UX with some progressive enhancements. The team had asked for better searching, filtering, pagination and report generation. Useful features, but not quite enough to justify pulling in React (my usual go-to for larger interactive apps). I considered lighter options like Qwik or Solid, but given the project was mostly server-rendered, I remembered HTMX — and after reviewing a few alternatives like Unpoly, I landed on HTMX. It was lean, focused, and fit the job.Getting things in place
With the decision made, I pulled in a few small packages with Composer:
league/plates
for views and partialsnikic/fast-route
for routingvlucas/phpdotenv
for managing environment valuessymfony/http-foundation
for clean request/response handling I bootstrapped everything through a simpleindex.php
, routed requests to handler classes, and added validation usingrakit/validation
. Nothing fancy — just splitting responsibilities so things weren’t bleeding all over the place.
// index.php (simplified)
$request = Request::createFromGlobals();
$uri = $request->getPathInfo();
$dispatcher = simpleDispatcher(function (RouteCollector $r) {
$r->addRoute('GET', '/mail', ['MailHandler', 'index']);
$r->addRoute('POST', '/login', ['AuthHandler', 'login']);
});
$routeInfo = $dispatcher->dispatch($request->getMethod(), $uri);
// ...
Breathing HTMX into it
Then I got to the fun part, HTMX.
This was my first time using it. Normally I wouldn’t reach for a new tool in the middle of a real project, but the HTMX docs were ridiculously approachable. No setup, no tooling, no mental overhead. Just:
<script src="https://unpkg.com/htmx.org@1.9.5"></script>
Dropped that into my layout.php
and within three minutes I had a working test:
<button hx-get="/mail/preview" hx-target="#main-panel" hx-swap="innerHTML">
Load Preview
</button>
I was surprised at how smooth the transition felt. No flickers, no loading jank. If I didn’t know better, I might’ve thought this was powered by React. It was that fast. But under the hood it was still just good ol' PHP returning partials. And it felt great.
Before we go any further, now might be a good time to explain what’s actually happening here — and what HTMX even is.
HTMX is a small JavaScript library that lets you make your HTML “live” by adding a few custom attributes. That’s it. No build tools, no virtual DOM, no client-side routing. Just declarative behavior layered directly into your markup.
Here’s that same example again:
<button
hx-get="/mail/preview"
hx-target="#main-panel"
hx-swap="innerHTML">
Load Preview
</button>
What’s happening:
hx-get="/mail/preview"
tells HTMX to issue a GET request to that URL when the button is clicked.hx-target="#main-panel"
tells it where to inject the response.hx-swap="innerHTML"
defines how to replace the content (in this case, just replace the inner HTML of #main-panel).
That’s it. No custom JS, no event listeners — just server-rendered HTML being dropped in. If the response contains markup, it’s swapped into place. And if your server is already doing that work (like mine was), then HTMX is basically free UI polish.
That said, there is a bit of nuance in how you handle partials vs full page views — especially if you're using HTMX to update just one part of the page but still want to support full refreshes or deep linking. But we’ll get to that in a bit.
The old flow
The original view was a single page with a long table of mail items. There wasn’t any pagination or filtering — just one big list of rows pulled from the database and echoed out into the HTML.
The project had been around for a while, and like a lot of internal tools, it had grown over time. It worked — and for the most part, it got the job done. But under the hood, things were showing their age.
A typical flow looked something like this:
$conn = mysqli_connect('localhost:3306', 'pioj', 'password', 'databasename');
$sql = "SELECT * FROM Mail ORDER BY dateReceived DESC";
$result = mysqli_query($conn, $sql);
while ($row = mysqli_fetch_assoc($result)) {
echo "<li><strong>{$row['subject']}</strong> from {$row['sender']}</li>";
}
There wasn’t a query builder or any routing setup — just raw queries and inline HTML. This pattern repeated across most pages.
The team used this screen heavily — one person would log incoming packages, and everyone else would scroll through the list throughout the day. But with no filtering and hundreds of entries piling up, even small tasks became harder than they needed to be.
The improved interface
I reworked it into a simple inbox-style layout:
A toolbar at the top with search + filters
A side panel listing paginated results
A main panel showing the selected mail item
All fully server-rendered, progressively enhanced with HTMX.
You can imagine how clean this becomes: click an item on the side, and HTMX loads the details into the main panel — no reload, no losing your place.
<!-- Sidebar item -->
<form
hx-get="/mail"
hx-target="#mail-side-panel"
hx-push-url="true"
hx-trigger="change delay:300ms, keyup changed delay:500ms from:input">
<input type="text" title="Search" name="search"
value="<?= $this->e($_GET['search'] ?? '') ?>"
/>
//...
</form>
<!-- Sidebar item -->
<div class="mail-item"
hx-get="/mail/<?= $mail['id'] ?>"
hx-target="#mail-main-panel"
hx-swap="innerHTML">
<strong><?= $mail['subject'] ?></strong>
<small><?= $mail['sender'] ?></small>
</div>
<!-- Main panel (initially empty or default state) -->
<div id="mail-main-panel">
<p>Select a mail item to view details</p>
</div>
This small form shows off what HTMX does best — progressive enhancement with zero JS overhead:
hx-get
hits the /mail route when input changeshx-target
swaps the result into the sidebarhx-push-url
syncs the query to the URLhx-trigger
adds debounced search behavior
It behaves like an instant search bar, but all I’m returning is a fresh server-rendered
Why it worked
The structure lent itself naturally to HTMX — discrete components with clear targets:
Toolbar → triggers filter requests
Sidebar → paginated, clickable list
Main panel → updated via hx-get on selection
And the best part? The server still handled everything — no client state to manage, no JS framework to hydrate.
But one neat trick that came up while building this: sometimes the same route needed to return different things, depending on whether the request came from HTMX or not.
For example, if someone clicked a mail item from the sidebar, I only needed to return the detail view for the main panel — a partial. But if someone visited that item’s link directly (e.g. /mail/123), I needed to render the entire page, layout and all.
HTMX makes this easy. It sends a special header on every request:
HX-Request: true
So on the server side, I could do this:
$isHtmx = $request->headers->get('HX-Request') === 'true';
if ($isHtmx) {
return new Response($this->views->render('partials/mail-read', $data));
}
return new Response($this->views->render('mail-detail', [
...$data,
'title' => 'Read Mail',
]));
Same controller/handler, same logic — just a different view depending on how the request came in. That made it easy to keep everything progressive without fragmenting the codebase into “HTMX-only” and “non-HTMX” paths. One of my initial concerns was whether HTMX would start to take over the codebase, but it turned out to be a minimal, lightweight tool with almost no footprint.
HTMX ended up enabling progressive enhancement in a few other areas of the app too — modals, filter panels, quick actions — but given how straightforward it is, it’s really just the same handful of hypermedia attributes reused in different contexts. Once the core concepts clicked, it was just a matter of sprinkling them where needed.
Final thoughts
HTMX didn’t change how the app worked — it just made it smoother to use. With minimal overhead, I was able to modernize the UI without giving up server-rendered workflows or dragging in a full frontend stack.
It’s not a replacement for React, Inertia, or Alpine — just another tool. The difference is that HTMX plays well with server-rendered apps by default and doesn’t demand a shift in architecture. Use it where it fits, ignore it where it doesn’t.
In the end, HTMX didn’t take over — it just helped the app breathe.