Přeskočit na obsah
iD-SIGN
Ptejte se

Formuláře v Symfony s htmx

Formuláře Symfony bez reloadu stránky: fragmenty z jedné šablony, validace se stavem 422, chybové odpovědi, redirecty, stránkování, filtry, živé hledání a aktualizace mimo target.

díl 2/3 6 min čtenívydáno 11/2026

V tomto článku 7 kapitol
  1. Celá stránka, nebo fragment
  2. Jak server pozná, co přišlo
  3. Jedna šablona pro stránku i fragment
  4. Formulář
  5. Stránkování a filtry
  6. Aktualizace mimo target
  7. Kontrolní seznam

Druhý díl seriálu je o formulářích a o všem, co na nich stojí: stránkování, filtrech a hledání. Navigaci, morph a krok zpět popisuje první díl, modální formuláře a Alpine.js třetí.

Celá stránka, nebo fragment

Vybírejte v tomto pořadí:

  1. Celá stránka přes boost. Formulář i odkaz se boostují, server vrací celou stránku a morph ji sloučí. Na serveru žádný kód navíc. Stačí pro většinu formulářů, výpisů, stránkování i filtrů.
  2. Fragment s vlastní routou. Jedna adresa, jedna reprezentace. Hodí se pro lazy loading.
  3. Tatáž adresa, dvě reprezentace. Formulář nebo výpis, který se mění uvnitř stránky a celá stránka je pro něj zbytečně drahá.

Jak server pozná, co přišlo

  • Boostovaný odkaz nebo formulář posílá HX-Request i HX-Boosted a chce celou stránku.
  • Prvek s vlastním hx-get nebo hx-post posílá jen HX-Request a chce fragment.
  • Krok zpět posílá jen HX-History-Restore-Request a chce celou stránku.

Kdo se ptá jen na HX-Request, pošle boostovanému odkazu fragment a stránka přijde o layout. Hlavička HX-Request-Type vypadá jako lepší rozlišení, ale extension hx-preload ji neposílá, takže prefetchnutý odkaz by dostal špatnou odpověď. Spolehlivá je kombinace:

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

Jedna šablona pro stránku i fragment

Fragment nemusí mít vlastní šablonu. renderBlock() vykreslí jen jeden blok šablony stránky:

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>Odeslat</button>
        {{ form_end(form) }}
    {% endblock %}
{% endblock %}

{% block sent %}<p id="contact-form">Děkujeme, ozveme se.</p>{% endblock %}

Pro fragment s vlastní routou je kratší atribut #[Template]:

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

Když stránka a fragment sdílejí adresu, nastavte Vary, ať cache nevrátí fragment místo stránky:

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

Formulář

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()) {
        // uložit a odeslat

        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() i renderBlock() nastaví stav 422, když dostanou odeslaný nevalidní FormInterface. S $form->createView() zůstane 200 a stav musíte nastavit sami.

Šablona

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 a method nechte, bez JavaScriptu se formulář odešle běžně.
  • hx-swap="outerHTML", ne morph. Morph zachovává hodnoty polí, formulář by po odeslání zůstal vyplněný.
  • hx-sync="this:drop" zahodí druhé odeslání. Pro stav během odeslání slouží hx-indicator, vlastní obsah do targetu vloží extension hx-pending.
  • novalidate, když chyby hlídá server. Firefox pro Android hlášky prohlížeče nezobrazuje, takže by prázdný formulář po kliknutí tiše stál. required u polí nechte kvůli čtečkám.

Chybové odpovědi

422 se swapne bez konfigurace. U fragmentu ale nechcete do formuláře vložit celou error page. hx-status hledá nejdřív přesný kód, až potom wildcard, takže 422 musí být uvedená zvlášť, jinak validační chyby tiše zmizí:

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' nestačí. hx-head sloučí hlavičku odpovědi ještě před vyhodnocením hx-status a htmx nastaví titulek, i když nic neswapne. Error page by tak přepsala titulek a hlavičku aktuální stránky. Head a titulek proto povolte jen při swapu celé stránky (u varianty s #app porovnejte target s tímto prvkem):

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

Chyba serveru nesmí zahodit text. U formuláře, kde uživatel něco dlouho psal, odpověď 5xx zahoďte a pod tlačítko vložte telefon a e-mail. Totéž při výpadku sítě:

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 po odeslání

  • Boostovaný formulář redirect zvládne a do historie zapíše konečnou adresu. Když má cíl jiný layout, dejte formuláři hx-boost="false". To platí hlavně pro přihlášení, kde authenticator po úspěchu přesměrovává, a pro odhlášení.
  • hx-post s obyčejným redirectem vloží celou cílovou stránku do targetu. Celá stránka v malém rámečku je typický příznak.
  • HX-Location v odpovědi 2xx přejde na jinou stránku bez plného načtení, změní adresu i titulek. Hodí se po úspěšném odeslání fragmentu.
  • HX-Redirect vynutí plné načtení, například na platební bránu nebo stránku s jiným layoutem. Na odpovědi 3xx htmx hlavičky nezpracovává a fetch redirect sám následuje.
  • HX-Refresh: true načte aktuální stránku znovu, třeba po přihlášení, kdy se mění celá hlavička.

Přístupnost

  • Po swapu bývá fokus na <body>. Po 422 ho dejte na první chybné pole, po odeslání na celé potvrzení. Obojí v htmx:after:settle.
  • Během odesílání použijte na tlačítku aria-disabled, ne disabled, který by fokus shodil na začátek stránky.
  • Stav odesílání ohlaste v předem vloženém prvku s role="status".

Stránkování a filtry

Celou stránkou

Filtr jako GET formulář a stránkování jako odkazy, obojí boostované. Server nic nerozlišuje, stav je v URL a krok zpět funguje sám. Tak to doporučujeme jako výchozí.

Fragmentem

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">Další</a>

Odkaz potřebuje href i hx-get. Bez href ho robot nenajde a bez JavaScriptu nefunguje. Controller vrátí blok přes renderBlock() a nastaví Vary. Stejného výsledku dosáhnete i bez rozlišování na serveru: hx-select="#articles" vybere část z celé stránky. Platí se za to větší odpovědí.

Infinite scroll

Poslední prvek seznamu načte další stranu, když se dostane do viewportu:

xml
<li hx-get="{{ path('app_blog', {page: page + 1}) }}"
    hx-trigger="revealed"
    hx-swap="outerHTML">
    <a href="{{ path('app_blog', {page: page + 1}) }}">Další články</a>
</li>

Odkaz uvnitř nechte. Robot neposouvá, takže bez něj by se k dalším stranám nedostal. Ve vlastním posuvném kontejneru místo okna použijte intersect s root:.

Živé hledání

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: pošle request až po pauze v psaní a jen při změně hodnoty, takže rychle napsané slovo znamená jeden request. hx-sync="this:replace" zruší rozpracovaný request, když přijde nový. Zrušený request htmx ohlásí v konzoli jako chybu, funkci to nevadí. Formulář kolem pole nechte, bez JavaScriptu se hledání odešle běžně.

SEO stránkování a filtrů

  • Každá strana výpisu má vlastní canonical s ?page= a titulek s číslem. Google nedoporučuje používat jako kanonickou první stranu. Do sitemapy další strany dávat nemusíte, vedou k nim odkazy.
  • hreflang na dalších stranách dejte, jen když mají jazykové verze stejné položky ve stejném pořadí. Jinak ho vynechte, odkaz na neexistující stranu je horší než žádný.
  • Filtrované a řazené varianty, které nemají být vstupní stránkou, vyřaďte přes noindex. canonical na výpis bez parametru je pro vyhledávač jen doporučení.
  • Formulářové POST cesty a výsledky hledání do sitemapy nepatří.

Aktualizace mimo target

Jedna odpověď může změnit víc míst na stránce, třeba hlášku ke slevovému kódu a souhrn ceny.

hx-swap-oob v odpovědi označí prvek, který se swapne podle svého id mimo hlavní target:

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

<hx-partial> je čitelnější, když se mění víc míst a každé jinak. Každý partial nese vlastní target a swap:

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

Na co si dát pozor:

  • Prázdný hlavní obsah se neswapne. Když po vyjmutí OOB prvků v odpovědi nic nezbude, htmx hlavní swap přeskočí. Po přijetí slevového kódu tak na stránce zůstala chyba z předchozího odmítnutého kódu vedle nové ceny. Pomůže hx-swap="innerHTML swapEmpty:true".
  • Řádky tabulky a položky seznamu zabalte do <template>, jinak je parser HTML mimo tabulku zahodí.
  • Seznam, kterému přibývají a mění se položky podle id, zvládne extension hx-upsert (hx-swap="upsert key:data-id sort"). Stejný obsah do víc míst naráz pošle hx-targets se selektorem.

Kontrolní seznam

  • Fragment jen při HX-Request && !HX-Boosted, ne podle HX-Request-Type
  • Fragment přes renderBlock() nebo #[Template(block:)], u společné adresy Vary
  • FormInterface do render() i renderBlock(), ne createView()
  • Formulář s action, outerHTML, hx-sync, novalidate
  • hx-status:422 vedle wildcardu 4xx, head a titulek jen při swapu celé stránky
  • Text formuláře přežije 5xx i výpadek sítě
  • Přihlášení a odhlášení bez boostu, po fragmentu HX-Location nebo HX-Redirect, ne 3xx
  • Fokus po 422 i po odeslání, aria-disabled, role="status"
  • Stránkování s href, stav filtrů v URL, canonical na každé straně, filtry s noindex
  • Odpověď s OOB a prázdným hlavním obsahem má swapEmpty:true

Štítky Backend, PHP, JavaScript

Sdílet LinkedIn Facebook

Potřebujete víc než článek?

Stačí pár vět o tom, co řešíte.