Zum Inhalt springen
iD-SIGN
Kontakt aufnehmen

Alpine.js in Symfony und Zusammenspiel mit htmx

Alpine.js über HTML aus Symfony: Strukturierung des Codes, Twig-Komponenten, Zusammenspiel mit htmx und Morph, modale Fenster mit History und Formularen und Situationen, in denen Alpine htmx ergänzt.

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

In diesem Artikel 9 Kapitel
  1. Prinzipien
  2. Start neben htmx
  3. Strukturierung des Codes
  4. Twig-Komponenten
  5. Zusammenspiel mit htmx
  6. Wo Alpine htmx ergänzt
  7. Modale Fenster
  8. Testen
  9. Checkliste

Der dritte Teil der Serie dreht sich um Alpine.js: wie man es in Symfony strukturiert, wie es neben htmx lebt und wo es Situationen aus dem ersten und zweiten Teil ergänzt.

Prinzipien

Alpine.js hält State im Browser über HTML, das der Server gerendert hat. Es rendert die Seite nicht aus Daten und braucht keine API. Die Aufteilung mit htmx ist einfach:

  • Daten und Entscheidungen gehören dem Server, in den Browser bringt sie htmx als HTML.
  • State, der den Server nicht interessiert, hält Alpine: ein offenes Menü, der gewählte Tab, eine Feldmaske, eine Berechnung vor dem Absenden, ein Ladezustand.
  • Was der Browser selbst kann, überlassen Sie ihm: <details> zum Aufklappen, <dialog> für ein modales Fenster. Alpine schaltet nur ihren Zustand um.

Direktiven, Magic Properties und Plugins zählen wir nur auf, beschrieben sind sie in der Dokumentation von Alpine.js:

  • Direktiven: x-data, x-init, x-show, x-if, x-for, x-bind, x-on, x-model, x-modelable, x-text, x-html, x-effect, x-ref, x-cloak, x-transition, x-teleport, x-id, x-ignore
  • Magic Properties: $el, $root, $refs, $store, $watch, $dispatch, $nextTick, $id, $data
  • offizielle Plugins: Mask, Intersect, Resize, Persist, Focus, Collapse, Anchor, Sort, Morph
  • Erweiterungen: Alpine.data(), Alpine.store(), Alpine.directive(), Alpine.magic()

Per x-show versteckte Elemente bekommen x-cloak mit der Regel [x-cloak] { display: none !important; }, sonst blitzen sie vor dem Start von Alpine auf.

Start neben htmx

javascript
import htmx from 'htmx.org'
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-alpine-compat.js'
import 'htmx.org/dist/ext/hx-head.js'
import Alpine from 'alpinejs'

// Hierher gehören Alpine.data() und Alpine.store(), sie müssen existieren, bevor Alpine den DOM durchgeht.

window.Alpine = Alpine
Alpine.start()

window.Alpine = Alpine ist nicht nur zum Debuggen da. hx-alpine-compat sucht Alpine genau auf window, ohne diese Zeile würde die Extension stillschweigend nichts tun.

Die Extension hx-alpine-compat ist beim Morph Pflicht. Ohne sie überschreibt Morph den DOM unter Alpine: x-text bleibt leer und ein angefangener Wert in x-model geht verloren. Mit ihr übersteht der State von Alpine den Morph.

Strukturierung des Codes

State hat drei Ebenen, und der Wechsel auf eine höhere sollte einen Grund haben:

  1. x-data im Template für ein paar Werte an einem Element: x-data="{ open: false }".
  2. Alpine.data() in einer Datei, sobald die Komponente Methoden oder mehr als zwei Zeilen Logik hat. Das lässt sich besser testen und lesen als Code im Attribut.
  3. Alpine.store() nur für State, der wirklich der ganzen Seite gemeinsam ist. Typischerweise reicht ein einziger: welches modale Fenster offen ist.

Jede Komponente kommt in eine eigene Datei, die sie registriert, und in der Einstiegsdatei rufen Sie nur die Registrierung vor Alpine.start() auf:

javascript
// assets/js/menu.js
export default function registerMenu(Alpine) {
    Alpine.data('menu', () => ({
        open: false,
        section: null,

        toggle() {
            this.open = !this.open
        },
    }))
}
javascript
// assets/app.js
import registerMenu from './js/menu.js'

registerMenu(Alpine)

window.Alpine = Alpine
Alpine.start()
xml
<nav x-data="menu">
    <button type="button" x-on:click="toggle()" x-bind:aria-expanded="open">Menü</button>
</nav>

Daten aus Twig

Werte vom Server fügen Sie nicht in den Ausdruck von x-data ein, er bricht am ersten Anführungszeichen. Übergeben Sie sie über data-*, Twig kodiert sie beim Autoescaping sicher:

xml
<div x-data="priceForm" data-plans="{{ plans|json_encode }}">
javascript
Alpine.data('priceForm', () => ({
    plans: [],

    init() {
        this.plans = JSON.parse(this.$el.dataset.plans)
    },
}))

Worauf Sie achten sollten

  • $el in einer Methode ist nicht die Wurzel der Komponente. Eine Methode, die per x-on:click an einem Button aufgerufen wird, bekommt $el dieses Buttons und würde nur darin suchen. Die Wurzel ist this.$root, $el ist nur in init() die Wurzel.
  • Custom Properties in x-bind:style schreiben Sie als String. Bei einem Objekt merkt sich Alpine vor der Änderung den vorherigen Wert über el.style['--x'], was bei Custom Properties immer undefined ist. Nach der Rückkehr bliebe am Element --x: undefined stehen.
  • Events schreiben Sie als x-on:click, nicht @click. Der Grund steht unten beim Zusammenspiel mit htmx.
  • $focus.next() aus dem Plugin Focus sucht innerhalb des Elements, an dem der Ausdruck steht. Am Button selbst tut es nichts, es gehört an den Wrapper.

Twig-Komponenten

  • Entscheidungen und Berechnungen gehören in die PHP-Klasse der Komponente, das Template rendert nur. Alpine bekommt fertige Werte über data-*.
  • Das Aussehen trägt ARIA, Alpine nur den State. Der Button hat x-bind:aria-expanded="open" und der Stil richtet sich nach dem Attribut (in Tailwind die Variante aria-expanded:). Barrierefreiheit und Aussehen können so nicht auseinanderlaufen.
  • Den Ausgangszustand rendert der Server. Filter, Tab oder Aufklappen haben auch ohne JavaScript die richtige Form, Alpine schaltet nur um. Ein Link bleibt ein Link mit Parameter in der URL.

Versteckten Inhalt wählen Sie danach, was an ihm teuer ist:

  • Teuer in der Beschaffung auf dem Server (Abfragen, Berechnungen, API-Aufrufe) und zugleich nicht kritisch für SEO, zum Beispiel die Bestellhistorie oder ein Preisverlaufsdiagramm in einem eingeklappten Abschnitt: laden Sie ihn erst beim Aufklappen per htmx, siehe Lazy Loading in Teil 1. x-if hilft hier nicht, den Inhalt im <template> rendert der Server bei jedem Aufruf der Seite.
  • Teuer nur für den Browser (große Inline-SVGs, Tausende Elemente): fügen Sie ihn per x-if ein, nicht per x-show. Per x-show versteckte Elemente bleiben im DOM und belasten jede Neuberechnung der Styles, auch wenn sie niemand sieht. Vorsicht beim Morph, siehe unten.
  • Wichtig für SEO: lassen Sie ihn im HTML, etwa in einem eingeklappten <details>. Was erst beim Aufklappen geladen wird, sieht der Crawler nicht.

Zusammenspiel mit htmx

x-on: statt @

Morph kopiert Attribute aus der neuen Seite per setAttribute(). WebKit lehnt den Namen @click ab, obwohl der HTML-Parser ihn akzeptiert. Morph bricht dann mittendrin ab, und auf der Seite bleibt eine Mischung aus neuem und altem Inhalt. Ohne View Transitions erscheint in der Konsole eine Meldung über einen ungültigen Namen, mit ihnen verschluckt htmx den Fehler und Sie sehen nichts. In Chrome und Firefox tritt der Fehler nicht auf.

Der Fehler zeigt sich nur bei Wechseln, bei denen Morph im neuen Inhalt auf ein Element mit @ stößt. Typischerweise also auf manchen Seiten, während andere funktionieren, was dazu verleitet, die Ursache im Inhalt der Seite zu suchen. Die Lösung ist, überall x-on: zu schreiben. x-bind: und die Kurzform : haben das Problem nicht.

htmx-Events in Alpine

xml
<nav x-data="menu" x-on:htmx:before:swap.window="open = false; section = null">

htmx-Events haben die Form htmx:phase:aktion. Die Form mit Bindestrich htmx:before-swap existiert nicht, und ein Listener darauf wird stillschweigend nie ausgelöst.

Was den Morph übersteht

Der State von Alpine übersteht den Morph, und das ist Vorteil und Falle zugleich.

Menüs, Dropdowns und andere geöffnete Elemente bleiben nach dem Wechsel offen. Schließen Sie sie in htmx:before:swap und setzen Sie auch den untergeordneten State zurück, sonst zeigt sich das Menü beim nächsten Öffnen direkt im Untermenü. Die Klasse am <body> (gesperrtes Scrollen unter dem Menü) setzen Sie per $watch mit document.body?.classList.

Inhalt aus x-if und x-for löscht Morph. Der Server schickt nur <template>, Morph entfernt danach die gerenderten Elemente und Alpine bringt sie nicht zurück, bis sich der State ändert. Typisch ist ein Assistent, in dem der Besucher seine Situation wählt und daneben per x-if eine Illustration erscheint: Nach einem Klick auf das Logo oder einem Sprachwechsel bleibt die Auswahl hervorgehoben und statt der Illustration eine leere Fläche, ohne Fehler in der Konsole. Es gibt zwei Lösungen:

  • hx-morph-skip-children am Wrapper des Templates. State und Inhalt bleiben, aber der Server aktualisiert den Inhalt des Wrappers nicht mehr.
  • Die Komponente beim Swap auf den Anfang zurücksetzen, nur wenn der Swap die Komponente enthält:
javascript
resetOnSwap(event) {
    const isSwapped = event.detail.tasks?.some((task) => task.target?.contains(this.$root))
    if (!isSwapped) return
    this.selected = null
}
xml
<section x-data="guide" x-on:htmx:before:swap.window="resetOnSwap($event)">

Gleiche innere id unter verschiedenen Containern (siehe Teil 1) übertragen auch die alten Knoten der Komponente in die neue Seite. Das Ergebnis ist ein ReferenceError und ein leeres x-text.

Fremde Bibliotheken

Ein Widget, das seinen DOM selbst verwaltet (Karte, Editor), löscht Morph, weil laut Server nichts darin ist. An die Wurzel des Widgets setzen Sie hx-morph-skip (friert Element und Attribute ein) oder hx-morph-skip-children (aktualisiert die Attribute der Wurzel, lässt die Kindelemente).

Diagramme lassen sich einfacher abbauen und neu erstellen. Morph kann das <canvas> behalten, und über den neuen Daten bliebe die alte Instanz:

javascript
document.addEventListener('htmx:before:swap', destroyCharts)
htmx.onLoad(() => initCharts())

initCharts() ermittelt eine bestehende Instanz über die Bibliothek, nicht über ein Attribut, das Morph löschen würde.

hx-live

Die Extension hx-live bringt reaktive Ausdrücke in htmx, ähnlich wie Alpine. Sind beide auf der Seite, gehört die Kurzform :text Alpine, und hx-live wertet sie nicht aus, sondern gibt nur eine Warning aus. Für hx-live neben Alpine schreiben Sie hx-live:text. Auf einer Website sollten Sie sich aber an eine der beiden halten.

Wo Alpine htmx ergänzt

Lazy Loading eines fremden Dienstes erst nach Zustimmung. Eine Karte eines Drittanbieters soll nicht von selbst laden, weil sie Daten des Besuchers senden würde. Alpine zeigt eine Überlagerung mit dem Hinweis, an wen die Daten gehen, und löst nach dem Klick ein Event aus, auf das ein Skript die Karte erst lädt:

xml
<div x-data="{ shown: false }">
    <div x-show="!shown">
        <p>Die Karte stellt Google bereit.</p>
        <button type="button" x-on:click="shown = true; $dispatch('map-requested')">Karte anzeigen</button>
    </div>
    <div data-map hx-morph-skip></div>
</div>

Rückmeldung im Formular vor dem Absenden. Feldmaske (x-mask="99/99/9999", $money), Zeichenzähler, Felder abhängig von einer Auswahl (x-show nach x-model). Die Validierung läuft weiter auf dem Server und liefert 422 (Teil 2), Alpine spart nur unnötiges Absenden.

Berechnung vor dem Absenden. Der Preis nach den gewählten Optionen wird sofort berechnet. Der Server berechnet ihn beim Speichern erneut, dem Browser vertraut er nicht.

Download einer Datei mit echtem Fortschritt. Statt einer geschätzten Zeit lädt Alpine die Datei per fetch, der Button zeigt den Zustand, bis die Antwort da ist, und speichert die Datei über einen temporären Link mit download. Den Namen nimmt es aus Content-Disposition.

Filter ohne Request. Für eine kleine Liste, die schon auf der Seite steht. Den Ausgangszustand rendert der Server nach dem Parameter in der URL, die Chips sind Links und Alpine schaltet den State ohne Laden der Seite um. Die kanonische Adresse ist die Liste ohne Parameter.

Event vom Server. Nach dem Hinzufügen zum Warenkorb genügt es, dass der Server die Anzahl der Artikel im Header HX-Trigger sendet, und Alpine liest sie aus dem Event:

php
$response->headers->set('HX-Trigger', json_encode(['cart-updated' => ['count' => $cart->count()]]));
xml
<span x-data="{ count: {{ cart.count }} }"
      x-on:cart-updated.window="count = $event.detail.count"
      x-text="count">{{ cart.count }}</span>

Ohne Daten (HX-Trigger: cart-updated) kommt das Event mit leerem detail. Wenn sich HTML ändert, ist hx-swap-oob aus Teil 2 direkter.

Modale Fenster

Der Nutzer erwartet, dass Zurück ein offenes Fenster schließt. htmx würde stattdessen aber die Seite neu laden. Und weil der State von Alpine den Morph übersteht, bliebe ein Fenster mit State im eigenen x-data auch auf der nächsten Seite offen. Deshalb:

  • Welches Fenster offen ist, hält ein Store. Es ist höchstens eines gleichzeitig offen, und mit der History ist eine einzige Stelle verknüpft.
  • Das Fenster ist ein natives <dialog>. Modale Ebene, Escape und Focus Trap liefert der Browser, der Store wird nur darauf abgebildet.
xml
<button type="button" x-on:click="$store.modal.open('client')">Detail</button>
<dialog x-effect="$store.modal.isOpen('client') ? ($el.open || $el.showModal()) : $el.close()"
        x-on:close="$store.modal.close()">…</dialog>

Store und Verknüpfung mit der History stehen in einer eigenen Datei, die genauso registriert wird wie Komponenten, also registerModal(Alpine) vor Alpine.start():

javascript
export default function registerModal(Alpine) {
    let entryPath = null
    const currentPath = () => location.pathname + location.search

    Alpine.store('modal', {
        current: null,
        open(name) {
            if (entryPath === null) {
                history.pushState({ modal: name }, '')
                entryPath = currentPath()
            } else {
                history.replaceState({ modal: name }, '')
            }
            this.current = name
        },
        close() {
            if (this.current === null) return
            this.current = null
            if (entryPath !== null) history.back()
        },
        isOpen(name) {
            return this.current === name
        },
    })
    const modal = Alpine.store('modal')

    // Zurück vom Eintrag des Fensters 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.
    window.addEventListener('popstate', (event) => {
        if (!event.state?.modal) return
        entryPath = currentPath()
        modal.current = event.state.modal
    })

    // Link aus dem Fenster: die neue Seite ersetzt den Eintrag des Fensters, sonst würde Zurück nur die Adresse zurückbringen.
    document.addEventListener('htmx:before:history:update', (event) => {
        if (entryPath !== null) event.detail.history.type = 'replace'
        entryPath = null
        modal.current = null
    })
}
  • Den Fokus nach dem Schließen geben Sie dem Element zurück, das ihn vor dem Öffnen hatte, aber erst nach dem Schritt in der History. Die Navigation API verschiebt ihn nach dem Schritt auf <body>. Warten Sie auf navigatesuccess, in Firefox auf popstate.
  • Das Modul für Scroll Restoration aus Teil 1 muss Einträge mit derselben Adresse überspringen, sonst würde eine ausstehende Scrollposition erst beim nächsten Swap springen.

Formulare im Fenster

  • Rendern Sie das Formular direkt in die Seite ({% include %} im Dialog), laden Sie es nicht lazy. Probleme mit dem Timing, der Verarbeitung versteckter Elemente und mit $refs entfallen. Der Preis ist, dass das CSRF-Token auf einer lange geöffneten Seite ablaufen kann, setzen Sie deshalb für diesen Fall eine verständliche Meldung über csrf_message und übersetzen Sie sie in der Domain validators.
  • Das Absenden swappt nur den Inhalt des Fensters: hx-post mit hx-target auf den Inhalt des Dialogs und hx-status aus Teil 2 (422 swappen, die übrigen 4xx und 5xx nicht).
  • Nach Erfolg wechseln Sie entweder per HX-Location oder HX-Redirect woandershin (der Wechsel schließt das Fenster), oder Sie schließen das Fenster mit einem Event vom Server und aktualisieren die Seite per hx-swap-oob:
php
$response->headers->set('HX-Trigger', 'modal-close');
xml
<body x-data x-on:modal-close.window="$store.modal.close()">

Testen

Fehler aus diesem Teil zeigen sich erst nach einem Wechsel zwischen Seiten und oft nur in einem Browser, manuelles Durchklicken findet sie nicht.

  • Wechsel zwischen allen Seiten. Ein Skript geht Seitenpaare durch, wechselt per htmx von der einen auf die andere und vergleicht den Hauptinhalt mit einem frischen Laden der Zielseite.
  • Auch in WebKit. Unter Linux ist am günstigsten WebKitWebDriver mit MiniBrowser aus den Paketen der Distribution und ein einfacher Client für das WebDriver-Protokoll.
  • Mit und ohne View Transitions. Mit ihnen werden Fehler verschluckt, ohne sie erscheinen sie wenigstens in der Konsole.
  • State nach einem Wechsel auf dieselbe Seite (Logo, Sprachwechsel): offenes Menü, gewählte Option, Inhalt aus x-if.

Checkliste

  • hx-alpine-compat geladen, Komponenten und Store vor Alpine.start() registriert
  • Events nur x-on:, kein @
  • Logik in Alpine.data() oder in PHP, Daten aus Twig über data-*
  • Aussehen über aria-*, Ausgangszustand vom Server gerendert, x-cloak an versteckten Elementen
  • htmx-Listener in der Form htmx:phase:aktion
  • Menü und untergeordneter State werden bei htmx:before:swap geschlossen und zurückgesetzt
  • x-if und x-for durch hx-morph-skip-children geschützt, oder die Komponente beginnt beim Swap neu
  • Fremde Widgets unter hx-morph-skip, Diagramme abgebaut und neu erstellt
  • Modale Fenster nur über einen Store und <dialog>, Zurück schließt das Fenster
  • Formulare im Fenster in die Seite gerendert, verständliche Meldung bei abgelaufenem CSRF
  • Wechsel in WebKit getestet, mit und ohne View Transitions

Schlagwörter Backend, PHP, JavaScript

Teilen LinkedIn Facebook

Brauchen Sie mehr als einen Artikel?

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