Skip to content
iD-SIGN
Contact us

Forms in Symfony with htmx

Symfony forms without a page reload: fragments from a single template, validation with status 422, error responses, redirects, pagination, filters, live search and updates outside the target.

part 2/3 8 min readpublished 11/2026

In this article 7 chapters
  1. Full page or fragment
  2. How the server tells what came in
  3. One template for the page and the fragment
  4. The form
  5. Pagination and filters
  6. Updates outside the target
  7. Checklist

The second part of the series is about forms and everything built on them: pagination, filters and search. Navigation, morph and the back button are covered in the first part, modal forms and Alpine.js in the third.

Full page or fragment

Choose in this order:

  1. Full page via boost. Forms and links are boosted, the server returns the full page and morph merges it. No extra code on the server. Enough for most forms, listings, pagination and filters.
  2. Fragment with its own route. One URL, one representation. Suitable for lazy loading.
  3. Same URL, two representations. A form or listing that changes inside the page, where rendering the full page is needlessly expensive.

How the server tells what came in

  • A boosted link or form sends both HX-Request and HX-Boosted and wants the full page.
  • An element with its own hx-get or hx-post sends only HX-Request and wants a fragment.
  • The back button sends only HX-History-Restore-Request and wants the full page.

If you check only HX-Request, you send a fragment to a boosted link and the page loses its layout. The HX-Request-Type header looks like a better distinction, but the hx-preload extension does not send it, so a prefetched link would get the wrong response. The reliable combination is:

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

One template for the page and the fragment

A fragment does not need its own template. renderBlock() renders just one block of the page template:

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

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

{% block sent %}<p id="contact-form">Thank you, we will get back to you.</p>{% endblock %}

For a fragment with its own route, the #[Template] attribute is shorter:

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

When the page and the fragment share a URL, set Vary so a cache does not return the fragment instead of the page:

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

The form

Controller

php
#[Route('/contact', 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()) {
        // save and send

        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]);
}

Both render() and renderBlock() set status 422 when they get a submitted, invalid FormInterface. With $form->createView() it stays 200 and you have to set the status yourself.

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',
    },
}) }}
  • Keep action and method, without JavaScript the form is submitted normally.
  • hx-swap="outerHTML", not morph. Morph keeps field values, so the form would stay filled in after submission.
  • hx-sync="this:drop" drops a second submission. hx-indicator shows the state during submission, the hx-pending extension puts custom content into the target.
  • novalidate when the server checks the errors. Firefox for Android does not display the browser's messages, so an empty form would silently do nothing after the click. Keep required on the fields for screen readers.

Error responses

422 is swapped without any configuration. For a fragment, though, you do not want a full error page inside the form. hx-status looks for the exact code first and only then for a wildcard, so 422 must be listed separately, otherwise validation errors silently disappear:

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' is not enough. hx-head merges the response's head before hx-status is evaluated, and htmx sets the title even when nothing is swapped. An error page would thus overwrite the title and head of the current page. Therefore allow the head and title only for a full-page swap (with the #app variant, compare the target with that 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
})

A server error must not throw away the text. For a form where the user wrote something long, discard the 5xx response and put a phone number and e-mail below the button. The same when the network fails:

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 after submission

  • A boosted form handles a redirect and writes the final URL to history. When the target has a different layout, give the form hx-boost="false". This applies mainly to login, where the authenticator redirects after success, and to logout.
  • hx-post with an ordinary redirect inserts the whole target page into the target element. A full page inside a small box is the typical symptom.
  • HX-Location in a 2xx response moves to another page without a full load and changes the URL and title. Useful after a fragment has been submitted successfully.
  • HX-Redirect forces a full page load, for example to a payment gateway or a page with a different layout. htmx does not process headers on a 3xx response, and fetch follows the redirect on its own.
  • HX-Refresh: true reloads the current page, for example after login, when the whole header changes.

Accessibility

  • After a swap, focus is often on <body>. After a 422 move it to the first invalid field, after submission to the whole confirmation. Both in htmx:after:settle.
  • During submission use aria-disabled on the button, not disabled, which would drop focus to the top of the page.
  • Announce the submission state in a pre-inserted element with role="status".

Pagination and filters

With the full page

A filter as a GET form and pagination as links, both boosted. The server does not distinguish anything, the state is in the URL and the back button works on its own. We recommend this as the default.

With a 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">Next</a>

The link needs both href and hx-get. Without href a crawler will not find it and it does not work without JavaScript. The controller returns the block via renderBlock() and sets Vary. You can get the same result without distinguishing on the server: hx-select="#articles" picks part of the full page. The price is a larger response.

Infinite scroll

The last item of the list loads the next page when it enters the viewport:

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

Keep the link inside. A crawler does not scroll, so without it it would never reach the next pages. In a custom scroll container instead of the window, use intersect with root:.

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: sends the request only after a pause in typing and only when the value has changed, so a quickly typed word means one request. hx-sync="this:replace" cancels a pending request when a new one comes. htmx reports the cancelled request in the console as an error, which does not affect functionality. Keep the form around the field, without JavaScript the search is submitted normally.

SEO for pagination and filters

  • Each page of a listing has its own canonical with ?page= and a title with the number. Google advises against using the first page as the canonical one. You do not need to put further pages in the sitemap, links lead to them.
  • Add hreflang on further pages only when the language versions have the same items in the same order. Otherwise leave it out, a link to a non-existent page is worse than none.
  • Filtered and sorted variants that should not be landing pages are excluded with noindex. A canonical pointing to the listing without parameters is only a hint for the search engine.
  • Form POST routes and search results do not belong in the sitemap.

Updates outside the target

One response can change several places on the page, for example the message about a discount code and the price summary.

hx-swap-oob in the response marks an element that is swapped by its id outside the main target:

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

<hx-partial> is more readable when several places change, each in a different way. Each partial carries its own target and swap:

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

Watch out for:

  • Empty main content is not swapped. When nothing remains in the response after the OOB elements are taken out, htmx skips the main swap. After a discount code was accepted, the error from the previously rejected code thus stayed on the page next to the new price. hx-swap="innerHTML swapEmpty:true" helps.
  • Wrap table rows and list items in <template>, otherwise the HTML parser discards them outside a table.
  • A list whose items are added and changed by id is handled by the hx-upsert extension (hx-swap="upsert key:data-id sort"). The same content into several places at once is sent by hx-targets with a selector.

Checklist

  • Fragment only for HX-Request && !HX-Boosted, not based on HX-Request-Type
  • Fragment via renderBlock() or #[Template(block:)], Vary for a shared URL
  • FormInterface passed to render() and renderBlock(), not createView()
  • Form with action, outerHTML, hx-sync, novalidate
  • hx-status:422 next to the 4xx wildcard, head and title only for a full-page swap
  • The form's text survives a 5xx and a network failure
  • Login and logout without boost, after a fragment HX-Location or HX-Redirect, not 3xx
  • Focus after 422 and after submission, aria-disabled, role="status"
  • Pagination with href, filter state in the URL, canonical on each page, filters with noindex
  • A response with OOB and empty main content has swapEmpty:true

Tags Backend, PHP, JavaScript

Share LinkedIn Facebook

Need more than an article?

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