Zum Inhalt springen

Live Validation with Symfony and HTMX

Veröffentlicht am Feb 5, 2025 | ca. 1 Min. Lesezeit |

Form validation in modern web applications is often a trade-off between convenience and complexity. Full JavaScript validation duplicates the server logic. A complete SPA framework is overkill for a contact form. HTMX offers a third way: server-side validation that embeds the result into the page via HTTP — without writing any custom JavaScript.

What is HTMX?

HTMX is a small JavaScript library (~14KB) that connects HTML elements with HTTP requests. Using data-hx-* attributes, you can define when a request is triggered, where it goes and where the result is inserted. The concept is called hypermedia-driven applications — the server returns HTML, not 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>

When the user leaves the email field (blur), HTMX sends a POST to /validate/email. The server responds with an HTML fragment that is inserted into #email-error.

Installation

HTMX can be included via CDN or npm. In a DDEV project:

ddev exec npm install htmx.org

Then include it in the base template:

<script src="{{ asset('assets/js/htmx.min.js') }}" defer></script>

Preparing the Symfony Form

Start with a standard Symfony form:

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

Validation Controller

The validation endpoint validates a single field and returns an HTML fragment:

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

The error template is minimal:

{# templates/validation/_error.html.twig #}
{% for error in errors %}
    <div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}

Twig Template with HTMX Attributes

In the form template, add the HTMX attributes to the input fields:

<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 Protection

HTMX requests are normal POST requests. Symfony checks CSRF tokens automatically when the form contains a _token field. Since HTMX does not perform a full form submission, you need to send the token manually:

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

In the controller, you then check the header:

$submittedToken = $request->headers->get('X-CSRF-Token');
if (!$this->isCsrfTokenValid('contact', $submittedToken)) {
    return new Response('', Response::HTTP_FORBIDDEN);
}

Progressive Enhancement

The beauty of this approach: without JavaScript (or if HTMX is not loaded), the form still works. Fields are validated on submit as usual — HTMX merely adds the live convenience. This is progressive enhancement in its purest form.

When HTMX, When an SPA Framework?

HTMX is a good fit when:

  • The form is primarily rendered server-side (Twig, PHP)
  • The validation logic is complex and should not be duplicated
  • A full JavaScript framework is too heavy

A framework like React or Vue makes sense when the entire UI is highly interactive and a lot of client-side state needs to be managed. For most traditional PHP web applications, HTMX is the more elegant solution.

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.