Zum Inhalt springen
iD-SIGN
Kontakt aufnehmen

Formulare in Symfony mit htmx

Symfony-Formulare ohne Reload der Seite: Fragmente aus einem Template, Validierung mit Status 422, Fehlerantworten, Redirects, Paginierung, Filter, Live-Suche und Updates außerhalb des Targets.

Teil 2/3 7 Min. Lesezeitveröffentlicht 11/2026

In diesem Artikel 7 Kapitel
  1. Ganze Seite oder Fragment
  2. Woran der Server erkennt, was kommt
  3. Ein Template für Seite und Fragment
  4. Formular
  5. Paginierung und Filter
  6. Updates außerhalb des Targets
  7. Checkliste

Der zweite Teil der Serie dreht sich um Formulare und alles, was auf ihnen aufbaut: Paginierung, Filter und Suche. Navigation, Morph und Zurück beschreibt der erste Teil, modale Formulare und Alpine.js der dritte.

Ganze Seite oder Fragment

Wählen Sie in dieser Reihenfolge:

  1. Ganze Seite per Boost. Formular und Link werden geboostet, der Server liefert die ganze Seite und Morph führt sie zusammen. Kein zusätzlicher Code auf dem Server. Reicht für die meisten Formulare, Listen, Paginierungen und Filter.
  2. Fragment mit eigener Route. Eine Adresse, eine Repräsentation. Eignet sich für Lazy Loading.
  3. Dieselbe Adresse, zwei Repräsentationen. Ein Formular oder eine Liste, die sich innerhalb der Seite ändert und für die die ganze Seite unnötig teuer ist.

Woran der Server erkennt, was kommt

  • Ein geboosteter Link oder ein geboostetes Formular sendet HX-Request und HX-Boosted und will die ganze Seite.
  • Ein Element mit eigenem hx-get oder hx-post sendet nur HX-Request und will ein Fragment.
  • Zurück sendet nur HX-History-Restore-Request und will die ganze Seite.

Wer nur nach HX-Request fragt, schickt einem geboosteten Link ein Fragment und die Seite verliert ihr Layout. Der Header HX-Request-Type wirkt wie die bessere Unterscheidung, aber die Extension hx-preload sendet ihn nicht, ein per Prefetch geladener Link bekäme also die falsche Antwort. Zuverlässig ist die Kombination:

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

Ein Template für Seite und Fragment

Ein Fragment braucht kein eigenes Template. renderBlock() rendert nur einen Block des Seiten-Templates:

xml
{# contact/index.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <h1>Kontakt</h1>
    {% block form %}
        {{ form_start(form, {attr: {id: 'contact-form'}}) }}
            {{ form_widget(form) }}
            <button>Senden</button>
        {{ form_end(form) }}
    {% endblock %}
{% endblock %}

{% block sent %}<p id="contact-form">Danke, wir melden uns.</p>{% endblock %}

Für ein Fragment mit eigener Route ist das Attribut #[Template] kürzer:

php
#[Route('/blog/auswahl', name: 'app_blog_picker')]
#[Template('blog/index.html.twig', block: 'articles')]
public function picker(ArticleRepository $articles): array
{
    return ['articles' => $articles->latest()];
}

Wenn Seite und Fragment eine Adresse teilen, setzen Sie Vary, damit der Cache nicht das Fragment statt der Seite liefert:

php
$response->setVary(['HX-Request', 'HX-Boosted'], false);

Formular

Controller

php
#[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->renderBlock('contact/index.html.twig', 'sent')
            : $this->redirectToRoute('app_contact');
    }

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

render() und renderBlock() setzen den Status 422, wenn sie ein abgeschicktes, ungültiges FormInterface bekommen. 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',
    },
}) }}
  • action und method behalten Sie, ohne JavaScript wird das Formular normal abgeschickt.
  • hx-swap="outerHTML", nicht Morph. Morph behält die Werte der Felder, das Formular bliebe nach dem Absenden ausgefüllt.
  • hx-sync="this:drop" verwirft ein zweites Absenden. Für den Zustand während des Absendens dient hx-indicator, eigenen Inhalt fügt die Extension hx-pending in das Target ein.
  • 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. required an den Feldern lassen Sie wegen der Screenreader.

Fehlerantworten

422 wird ohne Konfiguration geswappt. Bei einem Fragment wollen Sie aber keine ganze Error Page in das Formular einfügen. hx-status sucht zuerst den exakten Code, erst dann einen Wildcard, 422 muss also separat angegeben werden, sonst verschwinden Validierungsfehler stillschweigend:

xml
<form hx-post="…" hx-target="#order" 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 geswappt wird. Eine Error Page würde so Titel und Head der aktuellen Seite überschreiben. Head und Titel erlauben Sie deshalb nur beim Swap der ganzen Seite (bei der Variante mit #app vergleichen Sie das Target 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 Serverfehler darf den Text nicht verwerfen. Bei einem Formular, in das der Nutzer lange etwas geschrieben hat, verwerfen Sie eine 5xx-Antwort und fügen unter dem Button Telefon und E-Mail ein. Dasselbe 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 })
})

Redirect nach dem Absenden

  • Ein geboostetes Formular kommt mit einem Redirect zurecht und schreibt die endgültige Adresse in die History. Hat das Ziel ein anderes Layout, geben Sie dem Formular hx-boost="false". Das gilt vor allem für die Anmeldung, bei der der Authenticator nach Erfolg umleitet, und für das Abmelden.
  • hx-post mit einem gewöhnlichen Redirect fügt die ganze Zielseite in das Target ein. Eine ganze Seite in einem kleinen Rahmen ist das typische Symptom.
  • HX-Location in einer 2xx-Antwort wechselt ohne vollständiges Laden auf eine andere Seite und ändert Adresse und Titel. Eignet sich nach dem erfolgreichen Absenden eines Fragments.
  • HX-Redirect erzwingt ein vollständiges Laden, zum Beispiel zum Payment Gateway oder zu einer Seite mit anderem Layout. Bei einer 3xx-Antwort wertet htmx die Header nicht aus und fetch folgt dem Redirect von selbst.
  • HX-Refresh: true lädt die aktuelle Seite neu, etwa nach der Anmeldung, wenn sich der ganze Header ändert.

Barrierefreiheit

  • Nach dem Swap liegt der Fokus oft auf <body>. Nach 422 setzen Sie ihn auf das erste fehlerhafte Feld, nach dem Absenden auf die ganze Bestätigung. Beides in htmx:after:settle.
  • Während des Absendens verwenden Sie am Button aria-disabled, nicht disabled, das den Fokus an den Anfang der Seite werfen würde.
  • Den Zustand des Absendens melden Sie in einem vorab eingefügten Element mit role="status".

Paginierung und Filter

Mit der ganzen Seite

Filter als GET-Formular und Paginierung als Links, beides geboostet. Der Server unterscheidet nichts, der State steht in der URL und Zurück funktioniert von selbst. So empfehlen wir es als Standard.

Mit einem 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 der Crawler nicht und ohne JavaScript funktioniert er nicht. Der Controller liefert den Block per renderBlock() und setzt Vary. Dasselbe Ergebnis erreichen Sie auch ohne Unterscheidung auf dem Server: hx-select="#articles" wählt einen Teil aus der ganzen Seite aus. Der Preis ist eine größere Antwort.

Infinite Scroll

Das letzte Element der Liste lädt die nächste Seite, wenn es in den Viewport kommt:

xml
<li hx-get="{{ path('app_blog', {page: page + 1}) }}"
    hx-trigger="revealed"
    hx-swap="outerHTML">
    <a href="{{ path('app_blog', {page: page + 1}) }}">Weitere Artikel</a>
</li>

Den Link darin behalten Sie. Der Crawler scrollt nicht, ohne ihn käme er nicht zu den weiteren Seiten. In einem eigenen scrollbaren Container statt des Fensters verwenden Sie intersect mit root:.

Live-Suche

xml
<input type="search" name="q"
       hx-get="{{ path('app_search') }}"
       hx-trigger="keyup changed delay:300ms, search"
       hx-target="#results"
       hx-sync="this:replace">

changed delay: sendet den Request erst nach einer Tipppause und nur, wenn sich der Wert geändert hat, ein schnell getipptes Wort bedeutet also einen Request. hx-sync="this:replace" bricht einen laufenden Request ab, wenn ein neuer kommt. Den abgebrochenen Request meldet htmx in der Konsole als Fehler, der Funktion schadet das nicht. Das Formular um das Feld behalten Sie, ohne JavaScript wird die Suche normal abgeschickt.

SEO von Paginierung und Filtern

  • Jede Seite der Liste hat ein eigenes canonical mit ?page= und einen Titel mit der Nummer. Google rät davon ab, die erste Seite als kanonische zu verwenden. In die Sitemap müssen die weiteren Seiten nicht, Links führen zu ihnen.
  • hreflang auf weiteren Seiten setzen Sie nur, wenn die Sprachversionen dieselben Einträge in derselben Reihenfolge haben. Sonst lassen Sie es weg, ein Link auf eine nicht existierende Seite ist schlechter als keiner.
  • Gefilterte und sortierte Varianten, die keine Einstiegsseite sein sollen, schließen Sie per noindex aus. canonical auf die Liste ohne Parameter ist für die Suchmaschine nur eine Empfehlung.
  • POST-Pfade von Formularen und Suchergebnisse gehören nicht in die Sitemap.

Updates außerhalb des Targets

Eine Antwort kann mehrere Stellen der Seite ändern, etwa die Meldung zu einem Rabattcode und die Preisübersicht.

hx-swap-oob markiert in der Antwort ein Element, das nach seiner id außerhalb des Haupt-Targets geswappt wird:

xml
{% block discount_feedback %}
    {% if error %}<p>{{ error }}</p>{% endif %}
    <div id="price-summary" hx-swap-oob="outerHTML">…</div>
{% endblock %}

<hx-partial> ist übersichtlicher, wenn sich mehrere Stellen ändern, jede auf andere Weise. Jedes Partial trägt sein eigenes Target und seinen eigenen Swap:

xml
<hx-partial hx-target="#cart-items" hx-swap="beforeend">…</hx-partial>
<hx-partial hx-target="#cart-count">3</hx-partial>

Worauf Sie achten sollten:

  • Leerer Hauptinhalt wird nicht geswappt. Bleibt nach dem Herauslösen der OOB-Elemente in der Antwort nichts übrig, überspringt htmx den Haupt-Swap. Nach einem akzeptierten Rabattcode blieb so auf der Seite der Fehler des zuvor abgelehnten Codes neben dem neuen Preis stehen. Es hilft hx-swap="innerHTML swapEmpty:true".
  • Tabellenzeilen und Listeneinträge packen Sie in ein <template>, sonst verwirft der HTML-Parser sie außerhalb einer Tabelle.
  • Eine Liste, deren Einträge nach id hinzukommen und sich ändern, bewältigt die Extension hx-upsert (hx-swap="upsert key:data-id sort"). Denselben Inhalt an mehrere Stellen schickt hx-targets mit einem Selektor auf einmal.

Checkliste

  • Fragment nur bei HX-Request && !HX-Boosted, nicht nach HX-Request-Type
  • Fragment per renderBlock() oder #[Template(block:)], bei gemeinsamer Adresse Vary
  • FormInterface an render() und renderBlock(), nicht createView()
  • Formular mit action, outerHTML, hx-sync, novalidate
  • hx-status:422 neben dem Wildcard 4xx, Head und Titel nur beim Swap der ganzen Seite
  • Der Text des Formulars übersteht 5xx und einen Netzwerkausfall
  • Anmelden und Abmelden ohne Boost, nach einem Fragment HX-Location oder HX-Redirect, nicht 3xx
  • Fokus nach 422 und nach dem Absenden, aria-disabled, role="status"
  • Paginierung mit href, Filterzustand in der URL, canonical auf jeder Seite, Filter mit noindex
  • Antwort mit OOB und leerem Hauptinhalt hat swapEmpty:true

Schlagwörter Backend, PHP, JavaScript

Teilen LinkedIn Facebook

Brauchen Sie mehr als einen Artikel?

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