Formularvalidierung in modernen Webanwendungen ist oft eine Abwägung zwischen Komfort und Komplexität. Vollständige JavaScript-Validierung dupliziert die Serverlogik. Ein komplettes SPA-Framework ist Overkill für einen Kontaktformular. HTMX bietet einen dritten Weg: Server-seitige Validierung, die das Ergebnis via HTTP in die Seite einbettet — ohne eigenes JavaScript zu schreiben.
Was ist HTMX?
HTMX ist eine kleine JavaScript-Bibliothek (~14KB), die HTML-Elemente mit HTTP-Anfragen verbindet. Über data-hx-*-Attribute lässt sich festlegen, wann eine Anfrage ausgelöst wird, wohin sie geht und wo das Ergebnis eingefügt wird. Das Konzept nennt sich Hypermedia-getriebene Anwendungen — der Server gibt HTML zurück, kein JSON.
<input
type="email"
name="email"
hx-post="/validate/email"
hx-trigger="blur"
hx-target="#email-error"
hx-swap="innerHTML"
>
<div id="email-error"></div>
Wenn der Nutzer das E-Mail-Feld verlässt (blur), sendet HTMX einen POST an /validate/email. Der Server antwortet mit einem HTML-Fragment, das in #email-error eingefügt wird.
Installation
HTMX bindet man per CDN oder npm ein. Im DDEV-Projekt:
ddev exec npm install htmx.org
Dann in das Basis-Template einbinden:
<script src="{{ asset('assets/js/htmx.min.js') }}" defer></script>
Symfony-Formular vorbereiten
Man beginnt mit einem normalen Symfony-Formular:
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
class ContactType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 2, max: 100),
],
])
->add('email', EmailType::class, [
'constraints' => [
new Assert\NotBlank(),
new Assert\Email(),
],
])
->add('message', TextareaType::class, [
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 20, max: 2000),
],
]);
}
}
Validierungs-Controller
Der Validierungs-Endpoint validiert ein einzelnes Feld und gibt ein HTML-Fragment zurück:
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ValidationController extends AbstractController
{
public function __construct(
private readonly ValidatorInterface $validator,
) {
}
#[Route('/validate/email', name: 'validate_email', methods: ['POST'])]
public function validateEmail(Request $request): Response
{
$value = $request->request->get('email', '');
$violations = $this->validator->validate($value, [
new Assert\NotBlank(message: 'E-Mail darf nicht leer sein.'),
new Assert\Email(message: 'Bitte gültige E-Mail-Adresse eingeben.'),
]);
if (count($violations) === 0) {
return new Response('<span class="text-success">✓</span>');
}
$errors = [];
foreach ($violations as $violation) {
$errors[] = $violation->getMessage();
}
return $this->render('validation/_error.html.twig', [
'errors' => $errors,
]);
}
#[Route('/validate/name', name: 'validate_name', methods: ['POST'])]
public function validateName(Request $request): Response
{
$value = $request->request->get('name', '');
$violations = $this->validator->validate($value, [
new Assert\NotBlank(message: 'Name darf nicht leer sein.'),
new Assert\Length(
min: 2,
max: 100,
minMessage: 'Name muss mindestens 2 Zeichen lang sein.',
maxMessage: 'Name darf maximal 100 Zeichen lang sein.',
),
]);
if (count($violations) === 0) {
return new Response('<span class="text-success">✓</span>');
}
$errors = [];
foreach ($violations as $violation) {
$errors[] = $violation->getMessage();
}
return $this->render('validation/_error.html.twig', [
'errors' => $errors,
]);
}
}
Das Error-Template ist minimal:
{# templates/validation/_error.html.twig #}
{% for error in errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
Twig-Template mit HTMX-Attributen
Im Formular-Template fügt man die HTMX-Attribute zu den Eingabefeldern hinzu:
<form method="post" action="{{ path('contact') }}">
<div class="mb-3">
<label for="contact_name" class="form-label">Name</label>
{{ form_widget(form.name, {
'attr': {
'class': 'form-control',
'hx-post': path('validate_name'),
'hx-trigger': 'blur',
'hx-target': '#name-error',
'hx-swap': 'innerHTML',
'hx-include': '[name="contact[name]"]'
}
}) }}
<div id="name-error"></div>
</div>
<div class="mb-3">
<label for="contact_email" class="form-label">E-Mail</label>
{{ form_widget(form.email, {
'attr': {
'class': 'form-control',
'hx-post': path('validate_email'),
'hx-trigger': 'blur',
'hx-target': '#email-error',
'hx-swap': 'innerHTML',
'hx-include': '[name="contact[email]"]'
}
}) }}
<div id="email-error"></div>
</div>
<button type="submit" class="btn btn-primary">Senden</button>
</form>
CSRF-Schutz
HTMX-Requests sind normale POST-Requests. Symfony prüft CSRF-Tokens automatisch, wenn das Formular ein _token-Feld enthält. Da HTMX keine vollständige Formularübertragung macht, muss man das Token manuell mitschicken:
<meta name="csrf-token" content="{{ csrf_token('contact') }}">
<script>
document.addEventListener('htmx:configRequest', (event) => {
const token = document.querySelector('meta[name="csrf-token"]')?.content;
if (token) {
event.detail.headers['X-CSRF-Token'] = token;
}
});
</script>
Im Controller prüft man dann den Header:
$submittedToken = $request->headers->get('X-CSRF-Token');
if (!$this->isCsrfTokenValid('contact', $submittedToken)) {
return new Response('', Response::HTTP_FORBIDDEN);
}
Progressives Enhancement
Das Schöne an diesem Ansatz: Ohne JavaScript (oder wenn HTMX nicht geladen ist) funktioniert das Formular trotzdem. Die Felder werden beim Submit validiert wie gewohnt — HTMX fügt lediglich den Live-Komfort hinzu. Das ist progressives Enhancement in seiner reinsten Form.
Wann HTMX, wann ein SPA-Framework?
HTMX passt gut, wenn:
- Das Formular primär server-seitig gerendert wird (Twig, PHP)
- Die Validierungslogik komplex ist und nicht dupliziert werden soll
- Ein vollständiges JavaScript-Framework zu schwer wiegt
Ein Framework wie React oder Vue lohnt sich, wenn die gesamte UI hochgradig interaktiv ist und viel clientseitiger State verwaltet werden muss. Für die meisten traditionellen PHP-Webanwendungen ist HTMX die elegantere Lösung.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.