$npx -y skills add edutrul/drupal-ai --skill drupal-htmxHTMX in Drupal 11.3+ core — the Htmx PHP fluent builder, dynamic forms with swapOob, partial routes with _htmx_route, response headers, and Drupal.behaviors integration. Use when building interactive UI without full-page reloads using Drupal's native HTMX support.
| 1 | # Drupal HTMX |
| 2 | |
| 3 | HTMX 2.0.4 ships in Drupal 11.3+ core as `core/htmx`. No contrib module needed. |
| 4 | |
| 5 | ## When to use HTMX vs AJAX Form API |
| 6 | |
| 7 | | Scenario | Use | |
| 8 | |---|---| |
| 9 | | Form fields that update other fields | HTMX (`swapOob`) | |
| 10 | | Simple form submit with partial page update | HTMX or AJAX Form API | |
| 11 | | Complex multi-step AJAX with `AjaxResponse` commands | AJAX Form API | |
| 12 | | Non-form UI interactions (lazy load, infinite scroll, boost) | HTMX | |
| 13 | | Existing forms you're modifying | AJAX Form API (less disruption) | |
| 14 | |
| 15 | ## Attaching the Library |
| 16 | |
| 17 | ```yaml |
| 18 | # mymodule.libraries.yml |
| 19 | mymodule/htmx-feature: |
| 20 | dependencies: |
| 21 | - core/drupal.htmx |
| 22 | ``` |
| 23 | |
| 24 | `core/drupal.htmx` includes: htmx vendor JS + Drupal behaviors bridge + asset loader + drupalSettings integration. |
| 25 | |
| 26 | ## The `Htmx` PHP Fluent Builder |
| 27 | |
| 28 | ```php |
| 29 | use Drupal\Core\Htmx\Htmx; |
| 30 | use Drupal\Core\Url; |
| 31 | |
| 32 | $htmx = new Htmx(); |
| 33 | $htmx->post($url)->target('#my-wrapper')->swap('outerHTML'); |
| 34 | $htmx->applyTo($element); |
| 35 | ``` |
| 36 | |
| 37 | `applyTo()` writes `data-hx-*` attributes directly onto the render array element. Pass `'#wrapper_attributes'` as second argument to target the wrapper div instead. |
| 38 | |
| 39 | ```php |
| 40 | $htmx->applyTo($form['field'], '#wrapper_attributes'); |
| 41 | ``` |
| 42 | |
| 43 | ### Request Attributes |
| 44 | |
| 45 | ```php |
| 46 | $htmx->get(?Url $url) // data-hx-get |
| 47 | $htmx->post(?Url $url) // data-hx-post |
| 48 | $htmx->put(?Url $url) |
| 49 | $htmx->patch(?Url $url) |
| 50 | $htmx->delete(?Url $url) |
| 51 | $htmx->target('#selector') // data-hx-target |
| 52 | $htmx->swap('outerHTML') // data-hx-swap: innerHTML|outerHTML|beforebegin|afterbegin|beforeend|afterend|delete|none |
| 53 | $htmx->swapOob('true') // data-hx-swap-oob: out-of-band swap |
| 54 | $htmx->select('css selector') // data-hx-select: pick part of response |
| 55 | $htmx->selectOob(['#id:outerHTML']) // data-hx-select-oob |
| 56 | $htmx->trigger('click') // data-hx-trigger |
| 57 | $htmx->vals(['key' => 'val']) // data-hx-vals: extra POST values |
| 58 | $htmx->boost() // data-hx-boost: SPA-like link/form enhancement |
| 59 | $htmx->confirm('Are you sure?') |
| 60 | $htmx->on('click', 'alert()') // data-hx-on:click |
| 61 | $htmx->pushUrl(true) // data-hx-push-url |
| 62 | $htmx->onlyMainContent() // adds data-hx-drupal-only-main-content (triggers HtmxRenderer) |
| 63 | ``` |
| 64 | |
| 65 | ### Response Headers (applied to render array, sent with response) |
| 66 | |
| 67 | ```php |
| 68 | $htmx->pushUrlHeader($url) // HX-Push-Url |
| 69 | $htmx->redirectHeader($url) // HX-Redirect |
| 70 | $htmx->triggerHeader('myEvent') // HX-Trigger (also accepts array with data) |
| 71 | $htmx->triggerAfterSettleHeader('myEvent') // HX-Trigger-After-Settle |
| 72 | $htmx->refreshHeader(true) // HX-Refresh |
| 73 | // Also: replaceUrlHeader, locationHeader, reswapHeader, retargetHeader, reselectHeader, triggerAfterSwapHeader |
| 74 | ``` |
| 75 | |
| 76 | ## Pattern: Dependent Selects (Dynamic Forms) |
| 77 | |
| 78 | A select that updates another select without page reload. Each field posts to `<current>` with `swapOob` so both fields re-render out-of-band. |
| 79 | |
| 80 | ```php |
| 81 | public function buildForm(array $form, FormStateInterface $form_state, string $type = '', string $value = ''): array { |
| 82 | $formUrl = Url::fromRoute('<current>'); |
| 83 | |
| 84 | $form['type'] = [ |
| 85 | '#type' => 'select', |
| 86 | '#options' => ['a' => 'A', 'b' => 'B'], |
| 87 | '#default_value' => $type, |
| 88 | ]; |
| 89 | (new Htmx())->post($formUrl)->swap('none')->swapOob('true')->applyTo($form['type']); |
| 90 | |
| 91 | $form['value'] = [ |
| 92 | '#type' => 'select', |
| 93 | '#options' => $this->getOptionsFor($form_state->getValue('type', $type)), |
| 94 | '#default_value' => $value, |
| 95 | ]; |
| 96 | (new Htmx())->post($formUrl)->swap('none')->swapOob('true')->applyTo($form['value']); |
| 97 | |
| 98 | // Optionally push URL on change: |
| 99 | $trigger = $form_state->getUserInput()['_triggering_element_name'] ?? FALSE; |
| 100 | if ($trigger) { |
| 101 | (new Htmx())->pushUrlHeader(Url::fromRoute('mymodule.form', [...]))->applyTo($form[$trigger]); |
| 102 | } |
| 103 | |
| 104 | return $form; |
| 105 | } |
| 106 | |
| 107 | public function submitForm(array &$form, FormStateInterface $form_state): void {} |
| 108 | ``` |
| 109 | |
| 110 | ## Pattern: Partial Route (`_htmx_route`) |
| 111 | |
| 112 | A route that returns only its content fragment (no `<html>` shell). Use for controllers responding to HTMX `hx-get`/`hx-post` calls. |
| 113 | |
| 114 | ```yaml |
| 115 | # mymodule.routing.yml |
| 116 | mymodule.htmx_partial: |
| 117 | path: '/htmx/content/{id}' |
| 118 | defaults: |
| 119 | _controller: '\Drupal\mymodule\Controller\HtmxController::content' |
| 120 | _title: 'Content' |
| 121 | options: |
| 122 | _htmx_route: true # Uses HtmxRenderer — returns HTML fragment only |
| 123 | requirements: |
| 124 | _permission: 'access content' |
| 125 | id: '\d+' |
| 126 | ``` |
| 127 | |
| 128 | ```php |
| 129 | // Controller returns a render array normally — HtmxRenderer handles the rest. |
| 130 | public function content(int $id): array { |
| 131 | $node = $this->entityTypeManager->getStorage('node')->load($id); |
| 132 | return [ |
| 133 | '#theme' => 'mymodule_content_card', |
| 134 | '#node' => $node, |
| 135 | '#cache' => ['tags' => $no |