Zum Inhalt springen
iD-SIGN
Kontakt aufnehmen

Symfony mit htmx einfach und richtig

Eine vollständige Anleitung für Symfony und htmx 4: Navigation und Prefetch, die Zurück-Taste, Formulare, das Laden von Seitenteilen und SEO. Einschließlich der Fehler, die sich erst im Betrieb zeigen.

26 Min. Lesezeitveröffentlicht 11/2026

In diesem Artikel 12 Kapitel
  1. So funktioniert htmx
  2. Installation
  3. Navigation und Prefetch
  4. Zurück und vorwärts
  5. Formulare und Validierung
  6. Laden von Seitenteilen
  7. SEO
  8. Messung
  9. Tests und Deployment
  10. Checkliste für ein neues Projekt
  11. Wann Sie htmx nicht einsetzen sollten
  12. Übersicht über Attribute, Header und Events

Diese Anleitung fasst zusammen, was wir beim Bau mehrerer Projekte mit Symfony und htmx gelernt haben, von Content-Websites bis zu einer Anwendung mit Konten, Käufen und Zahlungen. Die Fehler darin sind nicht theoretisch: Die meisten haben wir tatsächlich gemacht, oder ein Code-Review hat sie entdeckt.

Die Anleitung gilt für htmx 4 und Symfony 8. Die Kombination mit clientseitigen Bibliotheken wie Alpine.js behandeln wir in einem eigenen Artikel.

So funktioniert htmx

Der Server liefert fertiges HTML und htmx fügt es in die Seite ein. Sie brauchen weder eine API für das Frontend noch eine zweite Schicht für Zustand und Validierung. Templates, Formulare und Sicherheit bleiben in Symfony.

Jede Interaktion durchläuft denselben Zyklus: Auslöser → Anfrage → Antwort → Auswahl eines Teils der Antwort → Austausch im DOM → Abschluss → Eintrag in die History. Attribute bestimmen, was in den einzelnen Schritten passiert. Eine Übersicht darüber, zusammen mit Austauscharten, Headern und Events, finden Sie am Ende des Artikels.

Regeln, die Sie vorab kennen sollten

  • Vererbung ist explizit. Nachfahren erhalten nur ein Attribut mit dem Suffix :inherited. Ohne es werden Links nicht geboostet und es erscheint kein Fehler.
  • Erweiterungen werden durch den Import aktiviert. Dafür ist kein Attribut nötig.
  • Events heißen htmx:phase:action, zum Beispiel htmx:before:swap. Ein Listener mit einem Tippfehler im Namen läuft stillschweigend nie.
  • Alles außer 204 und 304 wird eingefügt, auch Fehlerseiten.
  • Die History speichert keine Kopien von Seiten. Ein Schritt zurück lädt die Seite erneut vom Server.
  • Morph ist Teil des Kerns. Es braucht keine Erweiterung.

Installation

bash
npm install htmx.org@4

Die Einstiegsdatei funktioniert mit Vite über Symfony Reprise genauso wie mit Webpack Encore:

javascript
import htmx from 'htmx.org'
// Erweiterungen registrieren sich am globalen htmx, deshalb nach dem Kern importieren.
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-head.js'
import registerScrollRestore from './js/scroll-restore.js'

htmx.config.transitions = !matchMedia('(prefers-reduced-motion: reduce)').matches

registerScrollRestore()
  • htmx.initialize() müssen Sie nicht aufrufen, htmx startet von selbst, sobald das Modul ausgewertet ist. Registrieren Sie Erweiterungen und Listener deshalb direkt im Modul, nicht später.
  • Skripte gehören in den <head>. Das Morphen des <body>-Inhalts würde ein Skript am Seitenende wie ein gewöhnliches Element behandeln.

Grundeinstellung

Für die meisten Projekte genügt eine Einstellung am <body>:

xml
<body hx-boost:inherited="true"
      hx-swap:inherited="innerMorph"
      hx-head:inherited="merge">

Controller ändern sich nicht und liefern weiterhin ganze Seiten. Auf diese Weise kann sogar die Suche mit Filtern und Seitennavigation laufen, als gewöhnliche GET-Formulare und Links, ohne ein einziges Fragment und ohne dass der Server Header auswertet.

hx-swap:inherited gelangt auch zu Elementen mit eigenem hx-get oder hx-post. Diese sollten deshalb immer ein eigenes hx-swap haben.

Die zweite Variante verwenden Sie, wenn auf der Seite etwas außerhalb des Austauschs bleiben muss, zum Beispiel von einem fremden Skript eingefügte Elemente. Das Morphen des ganzen <body> würde sie entfernen, weil sie nicht in der Antwort stehen.

xml
<head hx-head="merge">
<body hx-boost:inherited="target:'#app', select:'#app', swap:'outerMorph ignoreTitle:true'">
<div id="app" hx-history-elt>
  • hx-head="merge" ist hier notwendig. Wenn das Ziel nicht der <body> ist, ergänzt hx-head den Head nur, und Titel und Meta-Tags häufen sich an.
  • Geben Sie das Ziel als Selektor an, nicht als target:this.
  • Setzen Sie hx-target und hx-select nicht auf #app, sonst erben sie alle Nachfahren.
  • Ohne hx-history-elt würde ein Schritt zurück den ganzen <body> austauschen.

Was Boost übernimmt und was Sie ausnehmen sollten

Boost gilt für Links und Formulare mit demselben Origin. Ausgelassen werden Links mit download, mit einem anderen target als _self und Links, die mit # beginnen. Einen Klick mit Strg, Cmd oder Umschalt überlässt es dem Browser.

Setzen Sie hx-boost="false" auf:

  • Links zur Administration oder zu einem anderen Bereich mit eigenen Assets,
  • Seiten mit einem völlig anderen Layout, zum Beispiel eine Anmeldung ohne Kopf- und Fußzeile,
  • das Anmeldeformular und Links mit einem Nebeneffekt,
  • einen Filterschalter, der seinen Zustand im Browser verwaltet.

Morphen zwischen unterschiedlichen Layouts ist ein unnötiges Risiko. Ein vollständiges Laden ist einfacher und zuverlässiger.

Morph und die Identität von Elementen

Morph ordnet Elemente nach Tag und id zu. Ein Element ohne id wird mit jedem Element mit demselben Tag an derselben Stelle zusammengeführt. Ein Element mit id wird nur mit einem Element mit derselben id zusammengeführt, sonst wird es neu eingefügt und das alte verschwindet. Deshalb kommt es auf die id des Hauptcontainers der Seite an.

  • Vergeben Sie dieselbe id nur an Seiten desselben Typs, zum Beispiel an zwei Artikeldetails. Dort ist das Zusammenführen sinnvoll und der Zustand bleibt erhalten. Seiten mit anderer Struktur sollten eine andere id haben. Ein einfacher Ersatz ist eine id nach Route, zwei Routen mit gleicher Struktur verlieren dadurch nur das Zusammenführen.
  • Die id gehört auf den Hauptcontainer, nicht auf den <body>. innerMorph führt nur die Kinder zusammen und liest oder ändert daher weder id noch Klasse des <body>. Ein Container ohne id verhält sich genauso wie ein Container mit gemeinsamer id.
  • Das Zusammenführen überträgt Zustand zwischen unzusammenhängenden Elementen. Bei gemeinsamer Container-id erscheint Text aus einem Feld search auf der nächsten Seite in einem Feld email, und ein Passwort erscheint lesbar in einem Textfeld, weil Morph den type ändert, den Wert aber behält. Ein direkt am Element hängender Handler der Schaltfläche „Löschen“ läuft beim Klick auf die neue Schaltfläche „Teilen“.
  • Formulare sollten eine eigene id haben. Bei unterschiedlichen Formular-ids wird der Wert auch unter einem gemeinsamen Container nicht übertragen. Beim Anmeldeformular ist das Pflicht.
  • Hängen Sie Listener per Delegation an document, oder entfernen Sie sie vor dem Austausch.
  • Ein Element, das den Übergang überleben soll, hat auf beiden Seiten dieselbe id. Es wird samt Wert übertragen, auch unter Containern mit unterschiedlichen ids.
  • Morph entfernt Attribute, die nicht in der Antwort stehen. Ein im Browser gesetztes Kennzeichen wie data-map-initialized verschwindet und das Widget wird ein zweites Mal initialisiert. Ergänzen Sie es in htmx.config.morphIgnore (das Standard-Array erweitern, nicht überschreiben), oder fragen Sie den Zustand bei der Bibliothek ab, zum Beispiel Chart.getChart(canvas).
  • Morph behält Feldwerte. Zum Zurücksetzen eines Formulars eignet es sich nicht, siehe den Abschnitt über Formulare.
  • Attributnamen müssen setAttribute() passieren. Morph kopiert Attribute damit, und Browser mit älterer Namensprüfung, zum Beispiel WebKit, lehnen einen Namen wie @click ab, die Kurzform aus Alpine.js. Das Zusammenführen bricht auf halbem Weg ab, auf der Seite bleibt eine Mischung aus neuem und altem Inhalt, und htmx verschluckt die Ausnahme, sodass in der Konsole nichts erscheint. Der HTML-Parser akzeptiert ein solches Attribut, in Chrome funktioniert also alles. Verwenden Sie die volle Form x-on:click.

Ein Element nehmen Sie mit dem Attribut hx-morph-skip aus. hx-morph-skip-children aktualisiert nur seine Attribute.

Seitenkopf und Sprache

Die Erweiterung hx-head führt den <head> nach exakter Übereinstimmung der Tags zusammen. Titel, Beschreibung, canonical und JSON-LD werden so nach dem Übergang ausgetauscht.

  • Markieren Sie Analyse- und andere einmalige Skripte im Head mit hx-preserve="true", sonst würden sie bei jedem Übergang gelöscht und neu ausgeführt.
  • <html lang> ändert sich nicht, weil htmx nur den Inhalt des <body> austauscht. Nach dem Sprachwechsel bleibt für Screenreader und Übersetzer die alte Sprache. Geben Sie dem Umschalter hx-boost="false", oder setzen Sie lang aus der Antwort:
javascript
document.addEventListener('htmx:before:swap', (event) => {
    const lang = event.detail.ctx.text?.match(/<html[^>]*\slang="([^"]*)"/i)?.[1]
    if (lang) document.documentElement.lang = lang
})
  • htmx schreibt den Titel erst nach dem Austausch. Bei aktivierten View Transitions kommt das Schreiben erst nach Ende des Übergangs, und ein zweiter schneller Klick kann dann im Tab den Titel der ersten Seite hinterlassen. ignoreTitle:true in hx-swap überlässt den Titel allein hx-head, das ihn sofort setzt.
  • Laden Sie Styles nicht aus lazy importiertem JavaScript. Vite fügt sie in den <head> ein, der nächste Übergang entfernt sie als unbekannt, und sie werden nicht erneut hinzugefügt. Laden Sie nur JavaScript lazy.

Prefetch

Die Erweiterung hx-preload lädt die Seite bereits bei mousedown oder touchstart. Beim Loslassen der Taste ist der Inhalt unterwegs oder fertig. Für geboostete Links genügt es, die Erweiterung zu laden, das Attribut ist nur nötig, um das Verhalten zu ändern:

xml
<body hx-preload:inherited="mousedown, touchstart">

Ein Prefetch ist eine echte Anfrage und nutzt nicht den HTTP-Cache, Sie müssen dafür also kein Cache-Control setzen. Er betrifft nur GET. Daraus folgt:

  • Ein GET mit Nebeneffekt muss hx-boost="false" haben. Prefetch unterscheidet keine Maustasten. Ein Abmeldelink per GET würde die Person schon beim Rechtsklick oder beim Beginn des Scrollens auf dem Handy abmelden. Das gilt für alles, was ein GET ändert, zum Beispiel das Abholen einer Flash-Nachricht.
  • Aufrufzähler zählen leicht zu viel, weil auch ein abgebrochener Klick zählt. Filtern Sie Prefetches nicht auf dem Server: Nach dem Klick ist der Prefetch meist die einzige Anfrage, sodass fast alle echten Aufrufe verschwinden würden. In einer Liste, in der die Scrollgeste oft auf einem Link beginnt, schränken Sie die Auslöser mit hx-preload:inherited="mousedown" ein.
  • Verlängern Sie die Gültigkeit nicht, wo persönlicher Zustand im Spiel ist. Die voreingestellten 5 Sekunden sind sinnvoll. Morph behält einen Link ohne id samt geladener Antwort, eine längere Gültigkeit würde nach einem Kauf also ein altes Guthaben anzeigen. Auf einer reinen Content-Website lässt sie sich verlängern, zum Beispiel mousedown timeout:30s.
  • Das Auslösen beim Überfahren mit der Maus haben wir verworfen. Die Erweiterung hat keine Verzögerung, das Überfahren eines Menüs rendert also alle Seiten dahinter. Eine eigene Verzögerung führte zu doppelten Anfragen und brachte gegenüber mousedown nur einige Dutzend Millisekunden.

Übergänge zwischen Seiten

htmx.config.transitions = true schaltet View Transitions ein. Das kostet Zeit: Mit der Standarddauer haben wir etwa 300 ms pro Übergang gemessen, wodurch eine Website mit Prefetch langsamer wirkt, als sie ist. Kürzen Sie alle drei Teile, die Länge des gesamten Übergangs bestimmt die Gruppe:

css
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 100ms;
}
  • Bei reduzierter Bewegung schalten Sie Übergänge gar nicht erst ein. Schon der Schnappschuss friert die Seite für die Dauer des Übergangs ein, auch wenn die Animation in CSS ausgeschaltet ist.
  • Derselbe view-transition-name an zwei Elementen bricht den Übergang ab.
  • Verwenden Sie kein globales scroll-behavior: smooth. Die Seite würde nach jedem Übergang und jedem Schritt zurück sichtbar scrollen. Schalten Sie weiches Scrollen nur für bestimmte Links ein.

Fehlerseiten

htmx fügt auch eine Fehlerantwort ein, ein geboosteter Link auf eine nicht vorhandene Seite zeigt also Ihre 404-Seite.

  • Templates in templates/bundles/TwigBundle/Exception/ brauchen das vollständige Layout.
  • In den Umgebungen dev und test werden sie nicht gerendert, weil dort kernel.debug aktiv ist. Probieren Sie sie auf einem prod-Build aus.
  • Bei der Variante mit #app würde eine Antwort ohne #app (502 vom Gateway während eines Deployments, ein fataler PHP-Fehler) den Inhalt löschen und eine leere Seite hinterlassen. Es hilft eine Absicherung, die die Navigation an den Browser übergibt:
javascript
document.addEventListener('htmx:before:swap', (event) => {
    const { ctx, tasks } = event.detail
    if (ctx.target?.id !== 'app') return

    const main = tasks.find((task) => task.type === 'main')
    if (main && main.fragment.childElementCount === 0) {
        event.preventDefault()
        window.location.assign(ctx.request.action)
    }
})

Abgelaufene Anmeldung

Wenn eine Anmeldung abläuft, leitet Symfony zur Anmeldeseite weiter und fetch folgt der Weiterleitung von selbst. Was danach passiert, hängt von der Anfrage und vom Layout der Anmeldeseite ab:

  • Geboosteter Link, Anmeldung im selben Layout wie die übrige Website: funktioniert ohne Änderungen. Die Anmeldeseite wird wie jede andere zusammengeführt und ihre Adresse in die History geschrieben.
  • Geboosteter Link, Anmeldung mit anderem Layout: Morphen zwischen unterschiedlichen Seiten ist ein unnötiges Risiko, und bei der Variante mit #app hat die Anmeldeseite dieses Element womöglich gar nicht.
  • Fragment mit eigenem Ziel: Die Anmeldeseite wird in das Ziel eingefügt, egal welches Layout sie hat.

In den letzten beiden Fällen hilft es, wenn AuthenticationEntryPoint bei htmx ein vollständiges Laden erzwingt:

php
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
    $loginUrl = $this->urlGenerator->generate('app_login');

    if ($request->headers->has('HX-Request')) {
        return new Response('', Response::HTTP_OK, ['HX-Redirect' => $loginUrl]);
    }

    return new RedirectResponse($loginUrl);
}

Vor diesem Aufruf speichert Symfony die Adresse der Anfrage als Ziel nach der Anmeldung und betrachtet eine htmx-Anfrage nicht als XHR. Aus dem Code des Frameworks folgt, dass die Person nach einem per GET angeforderten Fragment nach der Anmeldung beim nackten Fragment landen würde. Wenn Sie Fragmente hinter einer Anmeldung haben, entfernen Sie in diesem Zweig das gespeicherte Ziel mit TargetPathTrait::removeTargetPath().

Zurück und vorwärts

Hier weicht htmx 4 am stärksten von der üblichen Vorstellung ab. Es speichert beim Übergang keine Kopie der verlassenen Seite, ein Schritt zurück muss sie also erneut vom Server laden. Der Browser übernimmt das nicht: Sein Back/Forward-Cache funktioniert nur beim Wechsel zwischen Dokumenten, und ein geboosteter Übergang ändert das Dokument nicht.

Was passiert

  • In einem Browser mit Navigation API unterbricht htmx die Navigation, lädt die Seite und tauscht sie aus. In Firefox und in Browsern ohne diese API startet popstate dieselbe Arbeit.
  • Die Anfrage trägt nur HX-History-Restore-Request. Die Header HX-Request und HX-Boosted fehlen, der Server liefert also ohne jede Anpassung die ganze Seite.
  • Der Austausch läuft über outerSync, nicht über Morph. Die Kinder werden ersetzt, Komponentenzustand und angefangene Felder darin gehen also verloren.
  • hx-head führt bei der Wiederherstellung den Head aus der Antwort zusammen, der Titel stimmt also.

Was daraus folgt

  • Zustand, der einen Schritt zurück überleben soll, gehört in die Adresse. Filter, Seite oder Suche müssen in der URL stehen. Ein Fragment mit hx-push-url muss eine Adresse haben, unter der der Server die ganze Seite liefert.
  • Die Daten sind nach der Rückkehr aktuell, aber das Bedienen eines Schritts zurück ist eine gewöhnliche Anfrage, die Geschwindigkeit hängt also vom Server ab. Die Erweiterung hx-history-cache ändert dieses Verhalten, damit haben wir aber keine Erfahrung.
  • Widgets müssen sich zerstören und neu erstellen lassen. Zerstören Sie Diagramme in htmx:before:swap und erstellen Sie sie in htmx.onLoad.
  • Das Aufräumen von Zustand außerhalb des Austauschs gehört ebenfalls in htmx:before:swap, zum Beispiel das Entfernen einer Klasse, die bei geöffnetem Menü das Scrollen sperrt.

Scrollposition

Der Browser stellt die Scrollposition erst nach dem Ende der unterbrochenen Navigation wieder her. Die Seite wird deshalb zuerst oben gerendert und springt dann nach unten. Firefox macht es umgekehrt: Er scrollt noch den alten Inhalt, und nach dem Austausch wird die Position beim Wechsel von einer längeren auf eine kürzere Seite abgeschnitten. Die Lösung ist, sich die Position selbst nach dem Schlüssel des History-Eintrags zu merken:

javascript
const STORAGE_KEY = 'scroll-positions'
const LIMIT = 50

function load() {
    try {
        return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
    } catch (e) {
        return {}
    }
}

function store(positions) {
    const keys = Object.keys(positions)
    for (const key of keys.slice(0, Math.max(0, keys.length - LIMIT))) delete positions[key]

    try {
        sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
    } catch (e) { /* privates Fenster: die Position bleibt nur bis zum Neuladen erhalten */ }
}

export default function registerScrollRestore() {
    if (!window.navigation) return

    const positions = load()
    let pending = null

    // Dieselbe Bedingung, unter der htmx auf popstate hört, statt die Navigation zu unterbrechen.
    if (/firefox/i.test(navigator.userAgent)) {
        history.scrollRestoration = 'manual'

        const isHistoryLoad = performance.getEntriesByType('navigation')[0]?.type === 'back_forward'
        const target = positions[navigation.currentEntry.key]
        if (isHistoryLoad && target !== undefined) window.scrollTo(0, target)

        // Das Verlassen über die Adressleiste löst das Event navigate nicht aus.
        window.addEventListener('pagehide', () => {
            positions[navigation.currentEntry.key] = window.scrollY
            store(positions)
        })
    }

    // Muss vor dem htmx-Listener registriert werden, sonst kommt scroll: 'manual' zu spät.
    navigation.addEventListener('navigate', (event) => {
        positions[navigation.currentEntry.key] = window.scrollY
        store(positions)

        const isHistoryRestore = event.navigationType === 'traverse' && event.canIntercept && !event.hashChange

        // Einträge mit derselben Adresse stammen vom modalen Fenster: Die Seite ändert sich nicht und es folgt kein Austausch.
        if (isHistoryRestore && event.destination.url === navigation.currentEntry.url) {
            event.intercept({ scroll: 'manual' })
            return
        }

        const target = positions[event.destination.key]
        if (!isHistoryRestore || target === undefined) return

        pending = target
        event.intercept({ scroll: 'manual' })
    })

    const restore = () => {
        if (pending === null) return
        window.scrollTo(0, pending)
        pending = null
    }

    // Mit einer View Transition kommt htmx:after:swap erst nach dem Übergang. Das Scrollen
    // gehört deshalb ans Ende seiner Aufgabe, damit schon der neue Schnappschuss richtig steht.
    document.addEventListener('htmx:before:viewTransition', (event) => {
        if (pending === null) return

        const task = event.detail.task
        event.detail.task = async () => {
            await task()
            restore()
        }
    })

    document.addEventListener('htmx:after:swap', restore)
}

Browser ohne Navigation API lässt das Modul aus. Dort stellt der Browser die Position selbst wieder her, mit dem beschriebenen Sprung.

Modale Fenster

Man erwartet, dass Zurück ein offenes Fenster schließt. htmx würde stattdessen die Seite erneut laden. Ein Fenster fügt der History deshalb einen eigenen Eintrag mit derselben Adresse hinzu, und den Zustand aller Fenster hält eine einzige, mit der History verbundene Stelle:

javascript
let entryPath = null

const modal = {
    current: null,

    open(name) {
        history.pushState({ modal: name }, '')
        entryPath = location.pathname + location.search
        this.current = name
    },
}

// Ein Schritt zurück vom Fenstereintrag schließt nur das Fenster, die Seite wird nicht neu geladen.
document.addEventListener('htmx:before:history:restore', (event) => {
    const isLeavingEntry = entryPath === event.detail.path
    entryPath = null
    modal.current = null
    if (isLeavingEntry) event.preventDefault()
})

// Vorwärts öffnet das Fenster wieder. htmx lässt einen solchen Eintrag in Ruhe, weil es darin keinen eigenen Zustand findet.
window.addEventListener('popstate', (event) => {
    if (!event.state?.modal) return
    entryPath = location.pathname + location.search
    modal.current = event.state.modal
})

// Ein Link aus dem Fenster: Die neue Seite ersetzt den Fenstereintrag, sonst würde Zurück nur die Adresse wiederherstellen.
document.addEventListener('htmx:before:history:update', (event) => {
    if (entryPath !== null) event.detail.history.type = 'replace'
    entryPath = null
    modal.current = null
})
  • Das Schließen über das Kreuz oder Escape ruft history.back() auf, damit der Eintrag nicht in der History bleibt.
  • Nach einem Schritt in der History setzt die Navigation API den Fokus auf den <body>. Geben Sie den Fokus deshalb erst in navigatesuccess an die ursprüngliche Schaltfläche zurück, in Firefox nach popstate.
  • Verwenden Sie für das Fenster den nativen <dialog> und showModal(). Die modale Ebene, Escape und das Festhalten des Fokus bekommen Sie vom Browser.

Formulare und Validierung

Controller

Ein geboosteter Link sendet ebenfalls HX-Request. Wer nur diesen Header prüft, schickt dem Link ein Fragment statt einer Seite. Die Hilfsmethode prüft deshalb beide:

php
private function isFragmentRequest(Request $request): bool
{
    return $request->headers->has('HX-Request')
        && !$request->headers->has('HX-Boosted');
}

#[Route('/kontakt', name: 'app_contact', methods: ['GET', 'POST'])]
public function contact(Request $request): Response
{
    $form = $this->createForm(ContactType::class);
    $form->handleRequest($request);
    $isFragment = $this->isFragmentRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // Speichern und senden.

        return $isFragment
            ? $this->render('contact/_sent.html.twig')
            : $this->redirectToRoute('app_contact');
    }

    return $this->render(
        $isFragment ? 'contact/_form.html.twig' : 'contact/index.html.twig',
        ['form' => $form],
    );
}

AbstractController::render() setzt den Status 422 nur, wenn Sie ein FormInterface übergeben. Mit $form->createView() bleibt es bei 200 und Sie müssen den Status selbst setzen.

Template

xml
{{ form_start(form, {
    action: path('app_contact'),
    attr: {
        id: 'contact-form',
        novalidate: true,
        'hx-post': path('app_contact'),
        'hx-swap': 'outerHTML',
        'hx-sync': 'this:drop',
    },
}) }}
  • Behalten Sie action und method, ohne JavaScript wird das Formular normal abgeschickt.
  • hx-swap="outerHTML" und kein Morph. Morph behält Feldwerte, das Formular bliebe nach dem Absenden also ausgefüllt.
  • hx-sync="this:drop" verwirft ein zweites Absenden.
  • novalidate, wenn der Server die Fehler prüft. Firefox für Android zeigt die Meldungen des Browsers nicht an, ein leeres Formular würde nach dem Klick also stillschweigend stehen bleiben. Das Attribut required an den Feldern behalten Sie für Screenreader.

Fehlerantworten

Eine Validierungs-422 wird ohne Konfiguration eingefügt. Bei einem Fragment wollen Sie aber keine ganze Fehlerseite in das Formular einfügen. hx-status sucht zuerst den genauen Code und erst dann einen Platzhalter, 422 muss also eigens aufgeführt sein. Sonst verschwinden Validierungsfehler stillschweigend:

xml
<form hx-post="…" hx-target="#modal-content" hx-swap="innerHTML"
      hx-status:422="swap:'innerHTML'"
      hx-status:4xx="swap:'none'"
      hx-status:5xx="swap:'none'">

swap:'none' reicht nicht. hx-head führt den Head der Antwort noch vor der Auswertung von hx-status zusammen, und htmx setzt den Titel, auch wenn nichts ausgetauscht wird. Eine Fehlerseite würde so Titel und Head der aktuellen Seite überschreiben. Erlauben Sie Head und Titel deshalb nur beim Übergang auf eine ganze Seite. Bei der Variante mit #app vergleichen Sie das Ziel mit diesem Element:

javascript
const isFullPageSwap = (ctx) => ctx.target === document.body

document.addEventListener('htmx:head:before:merge', (event) => {
    if (!isFullPageSwap(event.detail.ctx)) event.preventDefault()
})

document.addEventListener('htmx:before:swap', (event) => {
    if (!isFullPageSwap(event.detail.ctx)) event.detail.ctx.title = null
})

Ein Fehler darf keinen Text verwerfen. Bei einem Formular, in dem jemand lange geschrieben hat, verwerfen Sie die 5xx-Antwort und fügen unter der Schaltfläche Telefon und E-Mail ein. Dasselbe tun Sie bei einem Netzwerkausfall:

javascript
document.addEventListener('htmx:before:response', (event) => {
    if (event.detail.ctx.response.status < 500) return

    event.preventDefault()
    event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})

document.addEventListener('htmx:error', (event) => {
    event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})

Weiterleitungen

  • Ein Ziel außerhalb der aktuellen Seite (Zahlungs-Gateway, anderes Layout): Geben Sie 200 mit HX-Redirect zurück. Bei einer 3xx-Antwort verarbeitet htmx keine Header, und fetch folgt der Weiterleitung von selbst.
  • hx-post mit einer gewöhnlichen Weiterleitung fügt die ganze Zielseite in hx-target ein. Eine ganze Seite in einem kleinen Rahmen ist das typische Symptom.
  • Ein geboostetes Formular bewältigt eine Weiterleitung und schreibt die endgültige Adresse in die History. Hat das Ziel aber ein anderes Layout, wählen Sie hx-boost="false". Das gilt vor allem für die Anmeldung, bei der der Authenticator nach dem Erfolg weiterleitet.

Formulare in modalen Fenstern

Rendern Sie ein Formular im Fenster direkt in die Seite, laden Sie es nicht lazy. So vermeiden Sie Probleme mit dem Timing und mit der Verarbeitung versteckter Elemente. Der Preis ist, dass das CSRF-Token auf einer lange geöffneten Seite ablaufen kann, bereiten Sie für diesen Fall also eine verständliche Meldung vor. In Symfony setzen Sie sie über csrf_message und übersetzen sie in der Domain validators.

Barrierefreiheit

  • Nach einem Austausch liegt der Fokus oft auf dem <body>. Setzen Sie ihn nach einer 422 auf das erste fehlerhafte Feld, nach dem Absenden auf die gesamte Bestätigung. Beides in htmx:after:settle.
  • Verwenden Sie beim Absenden an der Schaltfläche aria-disabled, nicht disabled, das den Fokus an den Seitenanfang werfen würde.
  • Melden Sie den Sendestatus in einem vorbereiteten Element mit role="status".

Laden von Seitenteilen

Wählen Sie in dieser Reihenfolge und beginnen Sie nicht mit dem Letzten:

  1. Die ganze Seite per Boost. Kein Code auf dem Server. Reicht für die meisten Listen, Seitennavigationen und Filter.
  2. Eine eigene Route für das Fragment. Eine Adresse, eine Darstellung. Geeignet für Lazy Loading.
  3. Dieselbe Adresse, zwei Darstellungen. Nur wenn die ganze Seite wirklich zu teuer ist.

Lazy Loading

Es lohnt sich bei Inhalten, die groß oder aufwendig in der Erstellung sind und die die meisten Menschen nie öffnen. Typischerweise ein Preisdiagramm, eine Bestellhistorie oder eine Dokumentvorschau in einem eingeklappten Abschnitt. Die Seite wird so nicht durch etwas aufgehalten, das die meisten Besucher nicht brauchen.

xml
<details>
    <summary>Preisentwicklung</summary>
    <div hx-get="{{ path('app_product_price_history', {id: product.id}) }}"
         hx-trigger="toggle once from:'closest details'"
         hx-swap="innerHTML"></div>
</details>
php
#[Route('/produkt/{id}/preisentwicklung', name: 'app_product_price_history', requirements: ['id' => '\d+'], methods: ['GET'])]
public function priceHistory(#[MapEntity(id: 'id')] Product $product): Response
{
    $response = $this->render('product/_price_history.html.twig', ['product' => $product]);
    $response->setPublic();
    $response->setMaxAge(3600);
    $response->headers->set('X-Robots-Tag', 'noindex');

    return $response;
}
  • Ein Selektor aus mehreren Wörtern in hx-trigger muss in Anführungszeichen stehen. Klammern versteht htmx 4 nicht, und ein Fehler bei der Verarbeitung stoppt die Verarbeitung der übrigen Seite.
  • Wenn ein Wert aus der Adresse ein Template oder eine Datei bestimmt, schränken Sie ihn mit requirements ein, damit sich darüber nichts anderes laden lässt.
  • Reservieren Sie Platz für den Inhalt, damit sich die Seite nach dem Laden nicht verschiebt.
  • Ein Fragment ohne personenbezogene Daten können Sie als öffentlich cachen.

Seitennavigation mit Fragment

xml
<a href="{{ path('app_blog', {page: page + 1}) }}"
   hx-get="{{ path('app_blog', {page: page + 1}) }}"
   hx-target="#articles"
   hx-swap="innerHTML"
   hx-push-url="true">Weiter</a>

Der Link braucht href und hx-get. Ohne href findet ihn kein Crawler, und ohne JavaScript funktioniert er auch nicht. Der Controller entscheidet über isFragmentRequest():

php
$response = $this->render(
    $this->isFragmentRequest($request) ? 'blog/_grid.html.twig' : 'blog/index.html.twig',
    ['articles' => $articles, 'page' => $page],
);
$response->setVary(['HX-Request', 'HX-Boosted'], false);

return $response;

Vary schützt davor, dass ein Cache ein Fragment statt der ganzen Seite liefert. Wer dasselbe Ergebnis über hx-select aus der ganzen Seite erhält, hat eine Adresse mit einer einzigen Darstellung, und der Server muss nichts unterscheiden. Der Preis ist die Übertragung einer größeren Antwort.

Aktualisierungen außerhalb des Ziels

hx-swap-oob aktualisiert mit einer Antwort weitere Stellen, zum Beispiel die Anzahl im Warenkorb oder eine Meldung zum Rabattcode.

  • Wenn nach dem Herauslösen der OOB-Elemente in der Antwort nichts übrig bleibt, überspringt htmx den Hauptaustausch. Eine leere Antwort löscht daher einen alten Fehler nicht. Es hilft hx-swap="innerHTML swapEmpty:true".
  • Packen Sie Tabellenzeilen und Listeneinträge in ein <template>, sonst verwirft sie der HTML-Parser.

JavaScript in geladenen Inhalten

  • Initialisieren Sie Widgets in htmx.onLoad(callback). Das läuft beim ersten Laden und nach jedem Austausch, auch beim Schritt zurück.
  • htmx:after:swap wird an dem Element ausgelöst, das die Anfrage gesendet hat, nicht am Ziel. Für die Initialisierung neuer Inhalte eignet es sich nicht.
  • Die Initialisierung muss wiederholbar sein.
  • Laden Sie schwere Bibliotheken über import(), erst wenn ihr Element auf der Seite ist.

SEO

htmx ist für Suchmaschinen unsichtbar, solange eine einzige Regel gilt: Jede Adresse liefert ohne JavaScript die ganze Seite. Ein Crawler sendet kein HX-Request und bekommt also das, was ein Mensch beim ersten Laden bekommt.

bash
curl -s https://example.com/leistungen | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/gibt-es-nicht | head -1

Regeln

  • Links sind <a href>. Ein Crawler folgt keinem hx-get an einer Schaltfläche oder einem <div>.
  • Was gefunden werden soll, steht im ersten HTML. Ein Crawler klickt nicht, scrollt nicht und öffnet keinen Abschnitt. Laden Sie über htmx nur Ergänzungen, wie ein Diagramm oder eine Historie. Text, der gefunden werden soll, lassen Sie im HTML, auch in einem eingeklappten <details>. Viele KI-Crawler führen gar kein JavaScript aus.
  • Titel, Beschreibung, canonical und hreflang stehen in der Antwort jeder Adresse. hx-head tauscht sie dann auch für den Nutzer aus.
  • Echte Statuscodes. Eine 404 muss eine 404 sein und ein Umzug eine 301. Eine Fehlerseite mit Status 200 käme in den Index.
  • Jede Seite einer Liste hat ein eigenes canonical. Die zweite Seite hat eine Adresse mit ?page= und einen Titel mit der Nummer. Google rät ausdrücklich davon ab, die erste Seite als kanonisch zu verwenden. Weitere Seiten müssen Sie nicht in die Sitemap aufnehmen, zu ihnen führen Links.
  • hreflang auf weiteren Seiten einer Liste hängt vom Inhalt ab. Wenn die Sprachversionen dieselben Einträge in derselben Reihenfolge haben und sich nur in der Übersetzung unterscheiden, verweisen Sie in jeder Sprache auf dieselbe Seite. Wenn sich die Anzahl der Einträge unterscheidet, lassen Sie hreflang auf weiteren Seiten weg, denn ein Link auf eine nicht vorhandene Seite ist schlechter als keiner.
  • Gefilterte und sortierte Varianten einer Liste, die keine Einstiegsseiten sein sollen, schließen Sie mit noindex vom Index aus, wie Google es empfiehlt. Eine kanonische Adresse auf die Liste ohne Parameter ist für die Suchmaschine nur ein Hinweis, den sie bei einer Seite mit anderem Inhalt nicht übernehmen muss.
  • Die Seitennavigation hat echte Links. Endloses Nachladen ohne Adressen ist für Crawler eine Sackgasse.
  • Fragmente gehören nicht in den Index. Eigene Routen bekommen X-Robots-Tag: noindex und stehen nicht in der Sitemap. Sperren Sie sie nicht in der robots.txt, sonst würde der Crawler den Header nicht lesen.
  • POST-Routen von Formularen und Suchergebnisse haben in der Sitemap nichts zu suchen.

Geschwindigkeit

Prefetch und Morph beschleunigen Übergänge, aber die Core Web Vitals in der Search Console beruhen vor allem auf dem ersten Laden. htmx ist klein, achten Sie trotzdem auf das Bundle und laden Sie schwere Bibliotheken lazy. Beobachten Sie auch die Antwortzeit des Servers, denn Prefetch und der Schritt zurück laufen über ihn.

Messung

Ein htmx-Übergang ist kein Seitenaufruf, die Analyse zählt die Aufrufe also nicht von selbst.

  • Senden Sie Ihre eigene Messung nach htmx:after:swap, nur wenn sich die Adresse geändert hat. Ein geöffnetes Fenster oder ein abgeschicktes Formular zählt dann nicht als Aufruf.
  • Der Titel ist zu diesem Zeitpunkt neu, weil hx-head ihn schon beim Eintreffen der Antwort zusammengeführt hat. Ein Event nach dem Schreiben in die History kommt noch vor dem Austausch des Inhalts, die Seite zeigt in diesem Moment also den alten Inhalt.
  • Google Tag Manager bewältigt Übergänge über den Trigger History Change. Wenn Sie zusätzlich selbst senden, haben Sie zwei Aufrufe.
  • Ein Schritt zurück zählt als Aufruf. Vergewissern Sie sich, dass Sie das so wollen.
  • Markieren Sie das Messskript mit hx-preserve="true".

Tests und Deployment

  • Probieren Sie vor jedem htmx-Update einen Übergang und ein Absenden des Formulars auch in einem älteren Browser aus sowie einen Schritt zurück aus einem modalen Fenster auf einer frisch geladenen Seite. Gehen Sie auch die Vorkommen von htmx: in den Templates durch.
  • In End-to-End-Tests warten Sie auf htmx:finally:request und hängen den Listener im selben Skript vor dem Klick an. Das Warten auf das Verschwinden der Klasse .htmx-request direkt nach dem Klick kann enden, bevor die Klasse überhaupt erscheint. Laden Sie nach dem Austausch den DOM im Test-Client neu.
  • Testen Sie den Schritt zurück in Chrome und Firefox, dort läuft er durch anderen Code. Probieren Sie es mit Übergängen und mit reduzierter Bewegung.
  • Probieren Sie Übergänge auch in WebKit aus, nicht nur in Chrome. Ein Morph-Fehler gibt dort unter Umständen nichts aus, Sie erkennen ihn daran, dass nach einem Klick ein Teil der alten Seite stehen bleibt. Unter Linux eignen sich dafür GNOME Web oder MiniBrowser aus WebKitGTK.
  • Testen Sie Fehlerseiten auf prod und probieren Sie einmal auch einen Ausfall ohne Ihr Template aus, zum Beispiel eine 502 vom Proxy.
  • Vorsicht bei einer offenen Seite während des Deployments. hx-head vergleicht Tags wörtlich. Aus dem htmx-Code folgt, dass ein Skript mit neuem Hash beim nächsten Übergang hinzugefügt wird und neben dem alten läuft. Im Betrieb haben wir das noch nicht gemessen, nach einem Deployment mit größerer Änderung lohnt sich aber eine Kontrolle.

Checkliste für ein neues Projekt

  • Die Erweiterungen hx-head und hx-preload nach dem Kern importiert
  • <body> mit hx-boost:inherited und Morph, oder #app mit hx-head="merge" und hx-history-elt
  • Alle Skripte im <head>, einmalige mit hx-preserve="true"
  • hx-boost="false" für Abmeldung, Anmeldung, Administration, andere Layouts und GET mit Nebeneffekt
  • <html lang> passt nach dem Sprachwechsel zur Seite
  • Fragment nur bei HX-Request && !HX-Boosted, bei derselben Adresse mit Vary
  • AuthenticationEntryPoint gibt bei htmx HX-Redirect zurück, wenn die Anmeldung ein anderes Layout hat oder Fragmente hinter einer Anmeldung liegen
  • Fehlertemplates mit vollständigem Layout, bei #app eine Absicherung gegen eine leere Auswahl
  • Übergänge gekürzt und bei reduzierter Bewegung aus, kein globales scroll-behavior: smooth
  • Modul zur Wiederherstellung der Scrollposition vor dem Start von htmx registriert
  • Zustand modaler Fenster an einer Stelle, mit der History verbunden
  • Zustand, der Zurück überleben soll, steht in der Adresse
  • Formulare: FormInterface an render(), outerHTML, hx-sync, hx-status:422 bei einem 4xx-Platzhalter, Textschutz bei 5xx, Fokus nach dem Austausch
  • Widgets in htmx.onLoad, wiederholbare Initialisierung, clientseitige Kennzeichen in morphIgnore
  • Attribute ohne @ im Namen, Übergänge auch in WebKit ausprobiert
  • SEO: curl liefert die ganze Seite, Links sind <a href>, Hauptinhalt im ersten HTML, Fragmente mit noindex
  • Messung nach htmx:after:swap, nicht nach einer History-Änderung
  • Schritt zurück in Chrome und Firefox getestet, Fehlerseiten auf prod

Wann Sie htmx nicht einsetzen sollten

htmx passt dort, wo der Server den Großteil der Arbeit erledigt: Content-Websites, Formulare, Konten, Administration. Für eine Anwendung, die viel Zustand im Browser hält, zum Beispiel einen Editor oder Offline-Arbeit, ist ein clientseitiges Framework die bessere Wahl.

Übersicht über Attribute, Header und Events

Attribute, die Sie verwenden werden

  • hx-get, hx-post: senden eine Anfrage an die angegebene Adresse.
  • hx-trigger: bestimmt das Event, das die Anfrage auslöst. Standard ist ein Klick, bei einem Formular das Absenden.
  • hx-target: bestimmt das Element, das der Austausch betrifft. Standard ist das Element selbst.
  • hx-swap: bestimmt die Austauschart. Standard ist innerHTML.
  • hx-select: wählt per Selektor nur einen Teil der Antwort aus.
  • hx-boost: macht aus einem gewöhnlichen Link oder Formular eine htmx-Anfrage.
  • hx-push-url: schreibt die Adresse in die Browser-History.
  • hx-sync: regelt gleichzeitige Anfragen, zum Beispiel das Verwerfen eines zweiten Absendens.
  • hx-status:CODE: ändert das Verhalten je nach Antwortstatus, zum Beispiel hx-status:422.
  • hx-swap-oob: markiert in einer Antwort ein Element, das außerhalb des Hauptziels ausgetauscht wird.
  • hx-preserve: lässt ein Element unverändert, wenn es auf der alten und der neuen Seite steht.
  • hx-history-elt: bestimmt das Element, das beim Schritt zurück ausgetauscht wird. Standard ist der <body>.

Austauscharten

  • innerHTML, outerHTML: ersetzen den Inhalt oder das ganze Element. Sinnvoll, wenn der Zustand verworfen werden soll.
  • innerMorph, outerMorph: führen neues HTML in das bestehende DOM ein und behalten Fokus, Scrollposition, Animationen und Feldwerte.
  • outerSync: führt die Attribute des Elements zusammen und ersetzt seine Kinder. htmx verwendet es beim Schritt zurück.
  • none: fügt nichts ein.
  • HX-Request: sendet jede gewöhnliche htmx-Anfrage, also auch ein geboosteter Link.
  • HX-Boosted: sendet nur ein geboostetes Element.
  • HX-History-Restore-Request: sendet die Wiederherstellung der Seite beim Schritt zurück.
  • HX-Redirect (in der Antwort): erzwingt ein vollständiges Laden der Seite. Funktioniert nur mit einer 2xx-Antwort.
  • HX-Retarget, HX-Reswap, HX-Push-Url (in der Antwort): damit überschreibt der Server Ziel, Austausch oder Adresse.

Events

Events steigen im DOM auf, es genügt also, Listener an document zu hängen. Die meisten tragen in event.detail.ctx den Kontext der Anfrage (ctx.target, ctx.request, ctx.response, ctx.text). Wo es angegeben ist, bricht event.preventDefault() den weiteren Ablauf ab. Die Listen stehen in der Reihenfolge, in der die Events eintreffen.

Anfrage:

  • htmx:config:request: Die Anfrage wird noch zusammengestellt. Hier ändern Sie Header, Adresse oder Body. Abbrechbar.
  • htmx:confirm: nur an einem Element mit hx-confirm. Für einen eigenen Bestätigungsdialog statt window.confirm, der über detail.issueRequest() oder detail.dropRequest() fortfährt.
  • htmx:before:request: unmittelbar vor dem Senden. Nützlich für den Zustand der Schaltfläche und die Meldung zum Senden. Abbrechbar.
  • htmx:before:response: Die Header sind da, der Body noch nicht. Durch Abbrechen verwerfen Sie die Antwort. Abbrechbar.
  • htmx:after:request: Der Body ist gelesen und hx-head hat den Head bereits zusammengeführt, der Austausch ist noch nicht erfolgt. Abbrechbar.
  • htmx:response:error: Der Status ist 400 oder höher. Der Austausch läuft trotzdem weiter.
  • htmx:error: Netzwerkausfall oder Ausnahme bei der Verarbeitung.
  • htmx:finally:request: immer zuletzt, auch nach einem Fehler oder Abbruch. Hierher gehört das Aufräumen.

Austausch des Inhalts:

  • htmx:before:swap: wird an dem Element ausgelöst, das die Anfrage gesendet hat. Die Adresse ist schon neu, der Inhalt noch alt. In detail.tasks stehen die vorbereiteten Austauschvorgänge. Abbrechbar.
  • htmx:before:viewTransition: nur bei eingeschaltetem Übergang. Über detail.task lässt sich Arbeit ergänzen, die noch in den neuen Schnappschuss kommt.
  • htmx:before:settle, htmx:after:settle: werden am Ziel des Austauschs ausgelöst, der neue Inhalt ist im DOM. Nützlich zum Setzen des Fokus.
  • htmx:after:process: htmx hat die Attribute im neuen Inhalt verarbeitet. Darauf baut htmx.onLoad() auf, das auch beim ersten Laden läuft.
  • htmx:after:viewTransition: Der Übergang ist beendet.
  • htmx:after:swap: wird an dem Element ausgelöst, das die Anfrage gesendet hat. Mit einem Übergang kommt es erst nach dessen Ende, also später als htmx:after:settle.
  • htmx:finally:swap: Ende des Austauschs, auch nach einem Fehler.
  • htmx:before:cleanup, htmx:after:cleanup: werden an einem Element ausgelöst, das htmx aus dem DOM entfernt.

History (ausgelöst an document):

  • htmx:before:history:update: Die Adresse wird gleich geschrieben. Über detail.history.type lässt sich push in replace ändern. Abbrechbar.
  • htmx:after:history:push, htmx:after:history:replace, htmx:after:history:update: Die Adresse ist geschrieben. Das kommt noch vor dem Austausch des Inhalts.
  • htmx:before:history:restore: ein Schritt zurück oder vorwärts, in detail.path steht die Zieladresse. Durch Abbrechen wird die Seite nicht erneut geladen.

Zusammenführen des Heads aus der Erweiterung hx-head (ausgelöst am <body>):

  • htmx:head:before:merge: Durch Abbrechen wird der Head aus der Antwort nicht verwendet.
  • htmx:head:before:add, htmx:head:before:remove: betreffen ein einzelnes Tag, durch Abbrechen bleibt es unberührt.
  • htmx:head:after:merge: Der Head ist zusammengeführt.

Schlagwörter Backend, PHP, JavaScript

Teilen LinkedIn Facebook

Brauchen Sie mehr als einen Artikel?

Ein paar Sätze zu Ihrem Anliegen genügen.