Skip to main content
Home
Drupal life hacks

Main navigation

  • Drupal
  • React
  • WP
  • Contact
  • About
User account menu
  • Log in

Breadcrumb

  1. Home

How Drupal 11.3 Makes Working with HTMX Easier: A Deep Dive into HtmxRequestInfoTrait

By admin, 14 October, 2025

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:

HeaderDescription
HX-RequestIndicates the request came from HTMX
HX-Trigger-NameName of the element that triggered it
HX-TargetThe 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:

MethodPurpose
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 ApproachNew Approach
Manually parsing headersUnified Drupal API
Inconsistent implementationsStandardized convention
More boilerplateMore 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:

  1. Returning HTMX-only (partial) responses
  2. Partial refresh of Blocks via HTMX
  3. 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

  1. Create a Block plugin that renders normally.
  2. Add a route/controller to return the block’s render array fragment.
  3. 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 #cache properly to avoid stale content. Use cache contexts/tags when possible.
  • CSRF tokens and forms. When HTMX posts form data, ensure tokens are included (hx-include can include the form or hidden token inputs).
  • File uploads. HTMX supports file uploads via hx-post with enctype and multipart/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).

Tags

  • #Drupal Planet
  • HTMX

Comments

About text formats

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Powered by Drupal