Drupal 11.3 introduced a small but powerful enhancement for developers working with HTMX — a new trait called HtmxRequestInfoTrait. This addition dramatically simplifies how we detect and process HTMX requests inside controllers, forms, and services.
In this article, we’ll cover:
- What HTMX is and how it integrates with Drupal
- What the new trait does
- Refactoring old-style code to the new API
- A real-world example of building a dynamic form with HTMX + Drupal
What is HTMX and Why Use It in Drupal?
HTMX is a lightweight JavaScript library that enables AJAX-like interactions without writing any JavaScript manually. Instead, you use declarative HTML attributes like this:
<button hx-post="/example" hx-target="#result">Refresh</button>
Whenever HTMX makes a request, it automatically sends useful metadata in HTTP headers:
| Header | Description |
|---|---|
HX-Request | Indicates the request came from HTMX |
HX-Trigger-Name | Name of the element that triggered it |
HX-Target | The CSS selector of the target element |
Until Drupal 11.3, there was no built-in API to work with these. Developers had to read the headers manually:
$trigger = $this->getRequest()->headers->get('HX-Trigger-Name');
What’s New in Drupal 11.3?
A new trait has been added:
Drupal\Core\Htmx\HtmxRequestInfoTrait
This trait is now automatically included in FormBase, and can be used in any controller or service that builds render arrays for HTMX responses.
It provides convenient helper methods:
| Method | Purpose |
|---|---|
isHtmxRequest() | Checks whether the request came from HTMX |
getHtmxTriggerName() | Returns which element triggered the request |
getHtmxTarget() | Returns the target selector (HX-Target) |
getHtmxCurrentUrl() | Provides the current URL post-redirect |
Refactoring Old Code to the New API
Before (manual header parsing):
$trigger = $this->getRequest()->headers->get('HX-Trigger-Name');
if ($trigger === 'config_type') { ... }
After (using the new trait):
if ($this->getHtmxTriggerName() === 'config_type') { ... }
Cleaner and more future-proof.
Real Example: Dynamic Form Field Rendering
Frontend (Twig)
<select hx-post="/admin/config/my/change" hx-target="#config-field" name="config_type">
<option value="a">Type A</option>
<option value="b">Type B</option>
</select>
<div id="config-field"></div>
Backend (Controller)
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Symfony\Component\HttpFoundation\Request;
class ConfigController {
use HtmxRequestInfoTrait;
protected function getRequest(): Request {
return \Drupal::request();
}
public function change() {
if ($this->getHtmxTriggerName() === 'config_type') {
return ['#markup' => '<input type="text" placeholder="Enter value"/>'];
}
return ['#markup' => 'Unknown trigger'];
}
}
Final Thoughts
| Old Approach | New Approach |
|---|---|
| Manually parsing headers | Unified Drupal API |
| Inconsistent implementations | Standardized convention |
| More boilerplate | More focus on business logic |
Drupal continues to evolve toward modern, reactive UI patterns, and HTMX is proving to be a powerful part of that story.
Building reactive Drupal UIs with HTMX — partial responses, block refresh, and Form API integration
HTMX lets you build highly dynamic UIs with minimal JavaScript by sending small HTML fragments from the server and rendering them in-place. In Drupal 11.3+, HtmxRequestInfoTrait helps read HTMX request metadata. In this article we’ll cover:
- Returning HTMX-only (partial) responses
- Partial refresh of Blocks via HTMX
- Combining HTMX with Drupal Form API and Ajax commands
All examples assume Drupal 11.3+ and HTMX included on the page (e.g., via CDN or library).
1) HTMX-only responses (partial responses)
Concept. When HTMX requests something, you usually want to return only the small HTML fragment that will be injected into the page, not the full page markup. That means controllers should return a render array containing only the fragment.
Key points
- Detect HTMX requests using
HtmxRequestInfoTrait::isHtmxRequest()(or check header). - Return a render array that represents only the HTML fragment HTMX should insert.
- Set appropriate cacheability metadata if fragment is cacheable.
Example controller (src/Controller/HtmxFragmentController.php):
<?php
namespace Drupal\htmx_example\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Symfony\Component\HttpFoundation\Request;
/
* Controller for HTMX fragments.
*/
class HtmxFragmentController extends ControllerBase {
use HtmxRequestInfoTrait;
protected function getRequest(): Request {
return \Drupal::request();
}
public function fragment() {
// If not an HTMX request, optionally redirect or return full page wrapper.
if (!$this->isHtmxRequest()) {
// Return a page with the fragment embedded, or redirect to a fallback route.
return [
'#type' => 'markup',
'#markup' => $this->t('This endpoint is intended for HTMX requests.'),
];
}
// Build a small fragment to be injected.
$build = [
'#type' => 'container',
'#attributes' => ['class' => ['htmx-fragment']],
'content' => [
'#markup' => '<p>Dynamic fragment at ' . date('H:i:s') . '</p>',
],
];
// Add cacheability if possible:
$build['#cache'] = ['max-age' => 0]; // dynamic, avoid caching by proxy.
return $build;
}
}
Routing (example)
htmx_example.fragment:
path: '/htmx/fragment'
defaults:
_controller: '\Drupal\htmx_example\Controller\HtmxFragmentController::fragment'
requirements:
_permission: 'access content'
Usage in Twig
<button hx-get="/htmx/fragment" hx-target="#result">Load fragment</button>
<div id="result"></div>
When clicked, HTMX issues a GET with HX-Request:true header; controller returns the fragment only.
2) Partial refresh of Blocks
Concept. Blocks are an ideal unit for partial refresh. You render a block normally for full page loads, but expose an endpoint that returns the block’s inner fragment for HTMX replacement.
Approach
- Create a Block plugin that renders normally.
- Add a route/controller to return the block’s render array fragment.
- On the page, HTMX targets the block’s inner wrapper (
#block-... .block-content) and replaces it.
Block plugin (src/Plugin/Block/TimeBlock.php):
<?php namespace Drupal\htmx_example\Plugin\Block; use Drupal\Core\Block\BlockBase; /* Provides a 'Time block' that can be refreshed via HTMX.
*
* @Block(
* id = "htmx_example_time_block",
* admin_label = @Translation("HTMX Time Block")
* )
*/
class TimeBlock extends BlockBase {
public function build() {
return [
'#type' => 'container',
'#attributes' => ['class' => ['htmx-time-block']],
'time' => [
'#markup' => $this->t('Current server time: @time', ['@time' => date('H:i:s')]),
],
'#cache' => ['max-age' => 0],
];
}
}
Controller that renders block fragment (src/Controller/BlockRefreshController.php):
<?php
namespace Drupal\htmx_example\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Drupal\block\Entity\Block;
use Symfony\Component\HttpFoundation\Request;
/**
* Returns block fragment for HTMX.
*/
class BlockRefreshController extends ControllerBase {
use HtmxRequestInfoTrait;
protected function getRequest(): Request {
return \Drupal::request();
}
public function refresh($uuid) {
// $uuid could be plugin ID or instance uuid — adapt as needed.
$build = [];
// Example: load a block by ID (plugin id) and render it.
// If using block content entities, adapt accordingly.
$block_manager = $this->get('plugin.manager.block');
$config = ['provider' => 'htmx_example', 'id' => 'htmx_example_time_block'];
$plugin = $block_manager->createInstance('htmx_example_time_block', []);
$build = $plugin->build();
// If this is an HTMX request, return only the inner fragment.
if ($this->isHtmxRequest()) {
// Return the block content fragment (render array).
return $build;
}
// Otherwise return a full page.
return ['#markup' => render($build)];
}
}
Twig usage (in theme or template where block placed):
Wrap block content in identifiable target:
<div id="time-block">
{{ drupal_block('htmx_example_time_block') }}
<button hx-get="/htmx/refresh/time" hx-target="#time-block">Refresh block</button>
</div>
When the button is clicked, HTMX replaces #time-block with content returned by /htmx/refresh/time.
Notes
- Be careful with cache contexts/tags. If block content depends on user, role, or route, include correct cache metadata.
- If returning just the inner
.block__content, ensure the returned HTML matches the selector targeted by HTMX.
3) Integrating HTMX with Form API and Ajax commands
Form API has its own ajax callbacks and commands (e.g. AjaxResponse, ReplaceCommand). HTMX offers a complementary approach: instead of FAPI ajax callbacks returning AjaxResponse objects, you can return a fragment render array and let HTMX inject it. However you can combine both.
Pattern A — Use HTMX instead of FAPI Ajax
Simpler: make the form or form field trigger HTMX requests and controller returns a fragment. Example: a select element triggers an HTMX POST to a custom route which returns new form element markup.
Twig form usage (embedding plain HTML that uses the form):
<form id="my-config-form" method="post" action="/admin/config/my">
{{ form }}
</form>
<select hx-post="/htmx/form/field" hx-include="#my-config-form" hx-target="#dependent-field" name="config_type">
<option value="a">A</option>
<option value="b">B</option>
</select>
<div id="dependent-field">
<!-- server will render this field fragment -->
</div>
Controller to return form field fragment:
public function updateFormField() {
$request = $this->getRequest();
$values = $request->request->all();
// Build a render array for just the dependent field.
$field = [
'#type' => 'textfield',
'#title' => $this->t('Dependent value'),
'#value' => isset($values['dependent_value']) ? $values['dependent_value'] : '',
];
// If necessary, wrap in form element properties to match styles.
return $field;
}
HTMX will send HX-Request headers. You can use getHtmxTriggerName() if you need to know which element triggered the update.
Pattern B — Use Form API and return AjaxResponse when needed (mixing)
If your form is already built with FAPI and uses #ajax callbacks that return AjaxResponse with ReplaceCommand, you can still use HTMX for some interactions. But HTMX expects raw HTML — it will not understand Drupal's AjaxResponse structure. So mixing requires converting AjaxResponse to plain HTML for HTMX endpoints, or have HTMX endpoints return fragments and FAPI Ajax continue to work separately.
Example: converting render array to HTML fragment
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Render\RendererInterface;
public function ajaxCallback(array &$form, FormStateInterface $form_state) {
// Standard FAPI AjaxResponse:
$response = new AjaxResponse();
$render = [
'#type' => 'markup',
'#markup' => '<div id="my-fragment">Hello</div>',
];
$response->addCommand(new ReplaceCommand('#my-fragment', $render));
return $response;
}
But for HTMX endpoint, you must return $render directly (not AjaxResponse) so HTMX can inject HTML.
Pattern C — Use HTMX to trigger form submission and use Drupal to validate/save
HTMX can hx-post the whole form. If the server returns a fragment that contains validation errors (rendered FAPI form), HTMX injects it into the page. This allows progressive enhancement:
- Submit via HTMX: server runs form validation and returns the form (or form part) with errors.
- On success, server returns success fragment (e.g., confirmation message).
Server-side: handle form state manually for HTMX endpoint
You can instantiate the form class, populate $form_state from request values, call its validateForm() and submitForm() methods programmatically, and render the updated form or success message. Alternatively, expose the form route and let the normal FAPI flow handle it — but HTMX will expect only the partial form fragment, so return just that.
Practical tips and gotchas
- Target selectors must match returned HTML. If HTMX replaces
#some-id .inner, the returned markup must contain the replaced element structure or the page may be inconsistent. - Cache metadata is important. Many fragments are dynamic — set
#cacheproperly to avoid stale content. Use cache contexts/tags when possible. - CSRF tokens and forms. When HTMX posts form data, ensure tokens are included (
hx-includecan include the form or hidden token inputs). - File uploads. HTMX supports file uploads via
hx-postwithenctypeandmultipart/form-data— but check server handling carefully. - Accessibility. HTMX updates must not break screen reader focus — consider adding ARIA live regions or focus management after replacements.
- Mixing FAPI Ajax & HTMX. Keep a clear separation: FAPI Ajax expects AjaxResponse objects; HTMX expects HTML fragments. Convert or separate endpoints accordingly.
Example module scaffold (quick)
htmx_example.info.yml
name: HTMX Example
type: module
description: 'Examples of HTMX usage in Drupal.'
core_version_requirement: ^11
package: Examples
htmx_example.routing.yml — add routes for fragment and block refresh as shown above.
src/Controller/HtmxFragmentController.php — fragment controller (example above).src/Plugin/Block/TimeBlock.php — block example (above).
Enable the module and add the block to a region. Include HTMX on the page (e.g., add CDN to a library and attach it to your theme or to the module's library).
Comments