Skip to content
iD-SIGN
Contact us

Alpine.js in Symfony and working with htmx

Alpine.js on top of HTML from Symfony: structuring code, Twig components, living alongside htmx and morph, modal windows with history and forms, and situations where Alpine complements htmx.

part 3/3 11 min readpublished 11/2026

In this article 9 chapters
  1. Principles
  2. Starting alongside htmx
  3. Structuring code
  4. Twig components
  5. Living alongside htmx
  6. Where Alpine complements htmx
  7. Modal windows
  8. Testing
  9. Checklist

The third part of the series is about Alpine.js: how to structure it in Symfony, how it lives alongside htmx and where it complements situations from the first and second part.

Principles

Alpine.js keeps state in the browser on top of HTML rendered by the server. It does not render the page from data and does not need an API. The split with htmx is simple:

  • Data and decisions belong to the server, htmx carries them to the browser as HTML.
  • State the server does not care about is kept by Alpine: an open menu, the selected tab, an input mask, a calculation before submission, a loading state.
  • What the browser can do on its own, leave to it: <details> for expanding, <dialog> for a modal window. Alpine only toggles their state.

We only list directives, magic properties and plugins, they are described in the Alpine.js documentation:

  • directives: 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
  • official plugins: Mask, Intersect, Resize, Persist, Focus, Collapse, Anchor, Sort, Morph
  • extensions: Alpine.data(), Alpine.store(), Alpine.directive(), Alpine.magic()

Elements hidden with x-show get x-cloak with the rule [x-cloak] { display: none !important; }, otherwise they flash before Alpine starts.

Starting alongside 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'

// Alpine.data() and Alpine.store() go here, they must exist before Alpine walks the DOM.

window.Alpine = Alpine
Alpine.start()

window.Alpine = Alpine is not just for debugging. hx-alpine-compat looks for Alpine exactly on window, without this line it would silently do nothing.

The hx-alpine-compat extension is required with morph. Without it, morph rewrites the DOM underneath Alpine: x-text ends up empty and a half-typed value in x-model is lost. With it, Alpine state survives morph.

Structuring code

State has three levels and moving to a higher one should have a reason:

  1. x-data in the template for a few values on one element: x-data="{ open: false }".
  2. Alpine.data() in a file as soon as the component has methods or more than two lines of logic. It is easier to test and read than code in an attribute.
  3. Alpine.store() only for state that is truly shared by the whole page. Usually one is enough: which modal window is open.

Put each component in its own file that registers it, and in the entry file just call the registration before Alpine.start():

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">Menu</button>
</nav>

Data from Twig

Do not put values from the server into the x-data expression, it breaks on the first quote. Pass them via data-*, Twig encodes them safely with autoescaping:

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

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

Watch out for

  • $el in a method is not the component root. A method called from x-on:click on a button gets $el of that button and would search only inside it. The root is this.$root; $el is the root only in init().
  • Write a custom property in x-bind:style as a string. With an object, Alpine remembers the previous value before a change via el.style['--x'], which is always undefined for custom properties. After going back, --x: undefined would remain on the element.
  • Write events as x-on:click, not @click. The reason is in the section on living alongside htmx below.
  • $focus.next() from the Focus plugin searches inside the element the expression is on. On the button itself it does nothing, it belongs on the wrapper.

Twig components

  • Decisions and calculations belong in the component's PHP class, the template only renders. Alpine gets finished values via data-*.
  • ARIA carries the look, Alpine only the state. The button has x-bind:aria-expanded="open" and the style follows the attribute (in Tailwind the aria-expanded: variant). Accessibility and appearance thus cannot drift apart.
  • The server renders the default state. A filter, tab or expandable section looks right even without JavaScript, Alpine only toggles it. A link stays a link with a parameter in the URL.

Choose how to handle hidden content based on what makes it expensive:

  • Expensive to obtain on the server (queries, calculations, API calls) and at the same time not critical for SEO, for example an order history or a price history chart in a collapsed section: load it only on expanding via htmx, see lazy loading in part 1. x-if does not help here, the server renders the content inside <template> on every page view.
  • Expensive only for the browser (large inline SVGs, thousands of elements): insert it with x-if, not x-show. Elements hidden with x-show stay in the DOM and weigh on every style recalculation, even when nobody sees them. Beware of morph, see below.
  • Important for SEO: keep it in the HTML, for example in a collapsed <details>. What is loaded only on expanding, a crawler will not see.

Living alongside htmx

x-on: instead of @

Morph copies attributes from the new page via setAttribute(). WebKit rejects the name @click, even though the HTML parser accepts it. Morph then stops halfway and the page is left with a mix of new and old content. Without View Transitions, a message about an invalid name appears in the console; with them, htmx swallows the error and you see nothing. In Chrome and Firefox the error does not show up.

The error appears only on transitions where morph hits an element with @ in the new content. Typically on some pages while others work, which tempts you to look for the cause in the page content. The solution is to write x-on: everywhere. x-bind: and the : shorthand do not have this problem.

htmx events in Alpine

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

htmx events have the form htmx:phase:action. The hyphenated form htmx:before-swap does not exist and a listener on it silently never fires.

What survives morph

Alpine state survives morph, which is both an advantage and a trap.

Menus, dropdowns and other open elements stay open after the transition. Close them in htmx:before:swap and reset the nested state too, otherwise the menu shows up right in the submenu the next time it opens. Set the class on <body> (scroll locked under the menu) via $watch with document.body?.classList.

Content from x-if and x-for is removed by morph. The server sends only <template>, morph removes the rendered elements accordingly and Alpine does not bring them back until the state changes. A typical case is a guide where the visitor chooses their situation and an illustration appears next to it via x-if: after clicking the logo or switching languages, the choice stays highlighted and there is an empty space instead of the illustration, without an error in the console. There are two solutions:

  • hx-morph-skip-children on the template's wrapper. State and content stay, but the server no longer updates the wrapper's content.
  • Reset the component on swap, but only when the swap contains the component:
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)">

The same inner id under different containers (see part 1) carries the component's old nodes into the new page as well. The result is a ReferenceError and an empty x-text.

Third-party libraries

A widget that manages its own DOM (a map, an editor) is wiped by morph, because according to the server there is nothing in it. Put hx-morph-skip on the widget's root (freezes the element and its attributes), or hx-morph-skip-children (updates the root's attributes, leaves the children alone).

Charts are easier to destroy and create again. Morph may keep the <canvas> and the old instance would stay on top of new data:

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

initCharts() detects an existing instance through the library, not through an attribute that morph would remove.

hx-live

The hx-live extension adds reactive expressions to htmx, similar to Alpine. When both are on the page, the :text shorthand belongs to Alpine and hx-live does not evaluate it, it only logs a warning. For hx-live next to Alpine, write hx-live:text. On a single website, though, stick to one of them.

Where Alpine complements htmx

Lazy loading a third-party service only after consent. A map from a third party should not load on its own, because it would send the visitor's data. Alpine shows an overlay saying who the data will be sent to, and after a click dispatches an event on which a script only then downloads the map:

xml
<div x-data="{ shown: false }">
    <div x-show="!shown">
        <p>The map is provided by Google.</p>
        <button type="button" x-on:click="shown = true; $dispatch('map-requested')">Show map</button>
    </div>
    <div data-map hx-morph-skip></div>
</div>

Feedback in a form before submission. An input mask (x-mask="99/99/9999", $money), a character count, fields shown based on a choice (x-show based on x-model). Validation still runs on the server and returns 422 (part 2), Alpine only saves unnecessary submissions.

Calculation before submission. The price based on the selected options is calculated immediately. The server calculates it again when saving, it does not trust the browser.

File download with real progress. Instead of guessing the time, Alpine downloads the file via fetch, the button shows the state until the response arrives, and the file is saved through a temporary link with download. The name is taken from Content-Disposition.

Filter without a request. For a small list that is already on the page. The server renders the default state based on a URL parameter, the chips are links and Alpine toggles the state without loading the page. The canonical URL is the listing without the parameter.

An event from the server. After adding to the cart, it is enough for the server to send the item count in the HX-Trigger header, and Alpine reads it from the 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>

Without data (HX-Trigger: cart-updated) the event arrives with an empty detail. When HTML changes, hx-swap-oob from part 2 is more straightforward.

Users expect Back to close an open window. htmx, however, would download the page again instead. And because Alpine state survives morph, a window with its state in its own x-data would stay open on the next page too. Therefore:

  • One store keeps which window is open. At most one is open at a time and only one place is tied to history.
  • The window is a native <dialog>. The browser provides the modal layer, Escape and the focus trap; the store is only projected into it.
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>

The store and the history wiring live in a separate file, registered the same way as components, i.e. registerModal(Alpine) before 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')

    // Back from the window's entry only closes the window, the page is not downloaded again.
    document.addEventListener('htmx:before:history:restore', (event) => {
        const isLeavingEntry = entryPath === event.detail.path
        entryPath = null
        modal.current = null
        if (isLeavingEntry) event.preventDefault()
    })

    // Forward opens the window again.
    window.addEventListener('popstate', (event) => {
        if (!event.state?.modal) return
        entryPath = currentPath()
        modal.current = event.state.modal
    })

    // A link from the window: the new page replaces the window's entry, otherwise Back would restore only the URL.
    document.addEventListener('htmx:before:history:update', (event) => {
        if (entryPath !== null) event.detail.history.type = 'replace'
        entryPath = null
        modal.current = null
    })
}
  • Return focus after closing to the element that had it before opening, but only after the history step. The Navigation API moves it to <body> after the step. Wait for navigatesuccess, in Firefox for popstate.
  • The scroll restoration module from part 1 must skip entries with the same URL, otherwise a pending scroll would jump only at the next swap.

Forms in a window

  • Render the form directly into the page ({% include %} in the dialog), do not lazy load it. This avoids timing issues, problems with processing hidden elements and with $refs. The price is that the CSRF token may expire on a page left open for a long time, so set a clear message for that case via csrf_message and translate it in the validators domain.
  • Submission swaps only the window's content: hx-post with hx-target pointing to the dialog's content and hx-status from part 2 (swap 422, not the other 4xx and 5xx).
  • After success, either move elsewhere via HX-Location or HX-Redirect (the transition closes the window), or close the window with an event from the server and update the page via hx-swap-oob:
php
$response->headers->set('HX-Trigger', 'modal-close');
xml
<body x-data x-on:modal-close.window="$store.modal.close()">

Testing

The errors in this part show up only after a transition between pages and often only in one browser; clicking through manually will not find them.

  • Transitions between all pages. A script goes through pairs of pages, moves from one to the other via htmx and compares the main content with a fresh load of the target page.
  • In WebKit too. On Linux the cheapest option is WebKitWebDriver with MiniBrowser from the distribution packages and a simple WebDriver protocol client.
  • With and without View Transitions. With them errors are swallowed, without them they at least appear in the console.
  • State after a transition to the same page (logo, language switch): open menu, selected choice, content from x-if.

Checklist

  • hx-alpine-compat loaded, components and store registered before Alpine.start()
  • Events only as x-on:, no @
  • Logic in Alpine.data() or in PHP, data from Twig via data-*
  • Look via aria-*, default state rendered by the server, x-cloak on hidden elements
  • htmx listeners in the form htmx:phase:action
  • Menu and nested state close and reset on htmx:before:swap
  • x-if and x-for protected by hx-morph-skip-children, or the component starts over on swap
  • Third-party widgets under hx-morph-skip, charts destroyed and created again
  • Modal windows only through one store and <dialog>, Back closes the window
  • Forms in a window rendered into the page, a clear message for an expired CSRF token
  • Transitions tested in WebKit, with and without View Transitions

Tags Backend, PHP, JavaScript

Share LinkedIn Facebook

Need more than an article?

A few sentences about what you are dealing with are enough.