HtmxTypeExtension Demo
This demo showcases the htmx option available on all Symfony form fields via HtmxTypeExtension.
Powered by PicoCSS + htmx
This demo showcases the htmx option available on all Symfony form fields via HtmxTypeExtension.
Powered by PicoCSS + htmx
Use fluent builder API with HtmxOptions:
use Mdxpl\HtmxBundle\Form\Htmx\HtmxOptions;
use Mdxpl\HtmxBundle\Form\Htmx\Trigger\Trigger;
$builder->add('search', TextType::class, [
'htmx' => HtmxOptions::create()
->getRoute('app_search')
->trigger(Trigger::keyup()->changed()->delay(300))
->target('#results')
->indicator('#spinner'),
]);
| Method | Attribute |
|---|---|
| get() / getRoute() | hx-get |
| post() / postRoute() | hx-post |
| trigger() | hx-trigger |
| target() | hx-target |
| swap() | hx-swap |
| indicator() | hx-indicator |
| include() | hx-include |
| vals() | hx-vals |
| confirm() | hx-confirm |
| onBeforeRequest() | hx-on::before-request |
Build complex triggers with Trigger class:
use Mdxpl\HtmxBundle\Form\Htmx\Trigger\Trigger;
// keyup changed delay:300ms
Trigger::keyup()->changed()->delay(300)
// blur changed delay:500ms
Trigger::blur()->changed()->delay(500)
// change (for selects)
Trigger::change()
// keyup[target.value.length >= 2]
Trigger::keyup()->condition('target.value.length >= 2')
Use placeholders for dynamic field values:
// Server-side: {name}, {id}, {full_name}
HtmxOptions::create()
->postRoute('app_validate', ['field' => '{name}'])
->target('#{id}-validation')
// Client-side: {value} (auto-generates JS)
HtmxOptions::create()
->getRoute('app_cities', ['country' => '{value}'])
->trigger(Trigger::change())
Use conditional to show/hide fields:
$builder
->add('accountType', ChoiceType::class, [
'choices' => [
'Personal' => 'personal',
'Business' => 'business',
],
'expanded' => true,
])
->add('business', BusinessFieldsType::class, [
'conditional' => [
'trigger' => 'accountType',
'endpoint' => '/form/business-fields',
],
]);
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Form\BusinessFieldsType;
use Mdxpl\HtmxBundle\Attribute\HtmxOnly;
use Mdxpl\HtmxBundle\Form\Htmx\HtmxOptions;
use Mdxpl\HtmxBundle\Form\Htmx\SwapStyle;
use Mdxpl\HtmxBundle\Form\Htmx\Trigger\Trigger;
use Mdxpl\HtmxBundle\Request\HtmxRequest;
use Mdxpl\HtmxBundle\Response\HtmxResponse;
use Mdxpl\HtmxBundle\Response\HtmxResponseBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/advanced-form')]
final class AdvancedFormController extends AbstractController
{
private const TEMPLATE = 'advanced_form.html.twig';
private const USERS = [
['id' => 1, 'name' => 'John Doe', 'email' => '[email protected]'],
['id' => 2, 'name' => 'Jane Smith', 'email' => '[email protected]'],
['id' => 3, 'name' => 'Bob Johnson', 'email' => '[email protected]'],
['id' => 4, 'name' => 'Alice Brown', 'email' => '[email protected]'],
['id' => 5, 'name' => 'Charlie Wilson', 'email' => '[email protected]'],
['id' => 6, 'name' => 'Diana Martinez', 'email' => '[email protected]'],
['id' => 7, 'name' => 'Edward Lee', 'email' => '[email protected]'],
['id' => 8, 'name' => 'Fiona Clark', 'email' => '[email protected]'],
];
private const LOCATIONS = [
'usa' => [
'name' => 'United States',
'cities' => [
'nyc' => 'New York',
'la' => 'Los Angeles',
'chi' => 'Chicago',
'hou' => 'Houston',
],
],
'uk' => [
'name' => 'United Kingdom',
'cities' => [
'lon' => 'London',
'man' => 'Manchester',
'bir' => 'Birmingham',
'edi' => 'Edinburgh',
],
],
'de' => [
'name' => 'Germany',
'cities' => [
'ber' => 'Berlin',
'mun' => 'Munich',
'ham' => 'Hamburg',
'fra' => 'Frankfurt',
],
],
'pl' => [
'name' => 'Poland',
'cities' => [
'war' => 'Warsaw',
'kra' => 'Krakow',
'wro' => 'Wroclaw',
'gda' => 'Gdansk',
],
],
];
#[Route('', name: 'app_advanced_form', methods: ['GET', 'POST'])]
public function index(HtmxRequest $htmx, Request $request): HtmxResponse
{
$formData = $request->request->all('form');
$isBusiness = ($formData['accountType'] ?? 'personal') === 'business';
$form = $this->createAdvancedForm($isBusiness);
$form->handleRequest($request);
$countries = array_combine(
array_map(static fn ($data) => $data['name'], self::LOCATIONS),
array_keys(self::LOCATIONS),
);
$selectedCountry = (string) $form->get('country')->getData();
$cities = $selectedCountry !== '' ? (self::LOCATIONS[$selectedCountry]['cities'] ?? []) : [];
$viewData = [
'form' => $form->createView(),
'countries' => $countries,
'cities' => $cities,
'isBusiness' => $isBusiness,
'routePrefix' => 'app_advanced_form',
];
$builder = HtmxResponseBuilder::create($htmx->isHtmx);
if ($form->isSubmitted()) {
if ($form->isValid()) {
return $builder
->success()
->viewBlock(self::TEMPLATE, 'submitSuccess', ['data' => $form->getData()])
->build();
}
$viewData['form'] = $form->createView();
return $builder
->failure()
->viewBlock(self::TEMPLATE, 'formContent', $viewData)
->triggerAfterSwap(['scrollTo' => '#form-error'])
->build();
}
if ($htmx->isHtmx) {
return $builder
->success()
->viewBlock(self::TEMPLATE, 'formContent', $viewData)
->build();
}
return $builder
->success()
->view(self::TEMPLATE, $viewData)
->build();
}
#[Route('/search/users', name: 'app_advanced_form_search_users', methods: ['GET'])]
#[HtmxOnly]
public function searchUsers(HtmxRequest $htmx, Request $request): HtmxResponse
{
$formData = $request->query->all('form');
$query = strtolower(trim((string) ($formData['user'] ?? '')));
$results = [];
if (\strlen($query) >= 2) {
$results = array_filter(
self::USERS,
static fn ($user) => str_contains(strtolower($user['name']), $query)
|| str_contains(strtolower($user['email']), $query),
);
$results = array_values($results);
}
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'searchResults', ['results' => $results])
->build();
}
#[Route('/cities/{country?}', name: 'app_advanced_form_cities', methods: ['GET'])]
#[HtmxOnly]
public function cities(HtmxRequest $htmx, ?string $country = null): HtmxResponse
{
$cities = $country !== null ? (self::LOCATIONS[$country]['cities'] ?? []) : [];
$isEmpty = $cities === [];
$cityForm = $this->createFormBuilder(options: ['csrf_protection' => false])
->add('city', ChoiceType::class, [
'label' => 'City',
'placeholder' => $isEmpty ? 'Select a country first...' : 'Select a city...',
'choices' => $isEmpty ? [] : array_flip($cities),
'disabled' => $isEmpty,
])
->getForm();
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'citySelect', [
'cityField' => $cityForm->get('city')->createView(),
])
->build();
}
#[Route('/validate/{field}', name: 'app_advanced_form_validate', methods: ['POST'])]
#[HtmxOnly]
public function validateField(
HtmxRequest $htmx,
Request $request,
ValidatorInterface $validator,
string $field,
): HtmxResponse {
$formData = $request->request->all('form');
$value = $formData[$field] ?? '';
$constraints = $this->getFieldConstraints($field);
$violations = $validator->validate($value, $constraints);
$errors = [];
foreach ($violations as $violation) {
$errors[] = $violation->getMessage();
}
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'fieldValidation', [
'errors' => $errors,
'field' => $field,
'isValid' => $errors === [] && $value !== '',
])
->build();
}
#[Route('/business-fields', name: 'app_advanced_form_business_fields', methods: ['GET'])]
#[HtmxOnly]
public function businessFields(HtmxRequest $htmx, Request $request): HtmxResponse
{
/** @var array<string, string> $formData */
$formData = $request->query->all('form');
$isBusiness = ($formData['accountType'] ?? 'personal') === 'business';
$form = $this->createFormBuilder(options: ['csrf_protection' => false])
->add('business', BusinessFieldsType::class, [
'is_required' => $isBusiness,
])
->getForm();
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'businessFields', [
'businessForm' => $form->get('business')->createView(),
'isBusiness' => $isBusiness,
])
->build();
}
#[Route('/validate/business/{field}', name: 'app_advanced_form_validate_business', methods: ['POST'])]
#[HtmxOnly]
public function validateBusinessField(
HtmxRequest $htmx,
Request $request,
ValidatorInterface $validator,
string $field,
): HtmxResponse {
/** @var array<string, array<string, string>> $formData */
$formData = $request->request->all('form');
$businessData = $formData['business'] ?? [];
$value = $businessData[$field] ?? '';
$constraints = $this->getBusinessFieldConstraints($field);
$violations = $validator->validate($value, $constraints);
$errors = [];
foreach ($violations as $violation) {
$errors[] = $violation->getMessage();
}
return HtmxResponseBuilder::create($htmx->isHtmx)
->success()
->viewBlock(self::TEMPLATE, 'fieldValidation', [
'errors' => $errors,
'field' => $field,
'isValid' => $errors === [] && $value !== '',
])
->build();
}
/**
* @return FormInterface<array<string, mixed>>
*/
private function createAdvancedForm(bool $requireBusinessFields = false): FormInterface
{
$countries = array_combine(
array_map(static fn ($data) => $data['name'], self::LOCATIONS),
array_keys(self::LOCATIONS),
);
$builder = $this->createFormBuilder(options: [
'csrf_protection' => false,
])
->add('user', TextType::class, [
'required' => false,
'label' => 'Search Users',
'attr' => [
'placeholder' => 'Type at least 2 characters...',
'autocomplete' => 'off',
],
'constraints' => [
new NotBlank(message: 'Please search for a user'),
new Choice(
choices: array_column(self::USERS, 'name'),
message: 'Please select a valid user from the list',
),
],
'htmx' => HtmxOptions::create()
->getRoute('app_advanced_form_search_users')
->trigger(Trigger::keyup()->changed()->delay(300)->condition('target.value.length >= 2'))
->target('#user-results')
->indicator('#search-spinner')
->onBeforeRequest('document.querySelector("#user-results").innerHTML = ""'),
])
->add('country', ChoiceType::class, [
'label' => 'Country',
'choices' => array_merge(['' => ''], $countries),
'constraints' => [
new NotBlank(message: 'Please select a country'),
],
'cascading' => [
'target' => 'city',
'endpoint' => '/advanced-form/cities/{value}',
],
]);
$addCityField = function (FormInterface $form, ?string $countryCode): void {
$cities = $countryCode !== null ? (self::LOCATIONS[$countryCode]['cities'] ?? []) : [];
$isEmpty = $cities === [];
$form->add('city', ChoiceType::class, [
'label' => 'City',
'placeholder' => $isEmpty ? 'Select a country first...' : 'Select a city...',
'choices' => $isEmpty ? [] : array_flip($cities),
'disabled' => $isEmpty,
'constraints' => [
new NotBlank(message: 'Please select a city'),
],
]);
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($addCityField): void {
$data = $event->getData();
$countryCode = \is_array($data) ? ($data['country'] ?? null) : null;
$addCityField($event->getForm(), $countryCode);
});
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($addCityField): void {
$data = $event->getData();
$countryCode = \is_array($data) ? ($data['country'] ?? null) : null;
$addCityField($event->getForm(), $countryCode);
});
$builder
->add('email', EmailType::class, [
'label' => 'Email',
'required' => false,
'attr' => ['placeholder' => 'Enter your email...'],
'constraints' => [
new NotBlank(message: 'Email is required'),
new Email(message: 'Please enter a valid email address'),
],
'htmx' => HtmxOptions::create()
->postRoute('app_advanced_form_validate', ['field' => '{name}'])
->trigger(Trigger::blur()->changed()->delay(500))
->target('#form_{name}-validation')
->swap(SwapStyle::InnerHTML),
])
->add('username', TextType::class, [
'label' => 'Username',
'required' => false,
'attr' => ['placeholder' => 'Choose a username...'],
'constraints' => [
new NotBlank(message: 'Username is required'),
new Length(min: 3, max: 20, minMessage: 'Username must be at least {{ limit }} characters', maxMessage: 'Username cannot exceed {{ limit }} characters'),
new Regex(pattern: '/^[a-zA-Z0-9_]+$/', message: 'Username can only contain letters, numbers and underscores'),
],
'htmx' => HtmxOptions::create()
->postRoute('app_advanced_form_validate', ['field' => '{name}'])
->trigger(Trigger::blur()->changed()->delay(500))
->target('#form_{name}-validation')
->swap(SwapStyle::InnerHTML),
])
->add('accountType', ChoiceType::class, [
'label' => 'Account Type',
'choices' => [
'Personal' => 'personal',
'Business' => 'business',
],
'expanded' => true,
'data' => 'personal',
])
->add('business', BusinessFieldsType::class, [
'required' => false,
'is_required' => $requireBusinessFields,
'conditional' => [
'trigger' => 'accountType',
'endpoint' => '/advanced-form/business-fields',
],
])
->add('submit', SubmitType::class, [
'label' => 'Submit Form',
]);
return $builder->getForm();
}
/**
* @return array<\Symfony\Component\Validator\Constraint>
*/
private function getFieldConstraints(string $field): array
{
return match ($field) {
'email' => [
new NotBlank(message: 'Email is required'),
new Email(message: 'Please enter a valid email address'),
],
'username' => [
new NotBlank(message: 'Username is required'),
new Length(min: 3, max: 20, minMessage: 'Username must be at least {{ limit }} characters', maxMessage: 'Username cannot exceed {{ limit }} characters'),
new Regex(pattern: '/^[a-zA-Z0-9_]+$/', message: 'Username can only contain letters, numbers and underscores'),
],
default => [],
};
}
/**
* @return array<\Symfony\Component\Validator\Constraint>
*/
private function getBusinessFieldConstraints(string $field): array
{
return match ($field) {
'companyName' => [
new NotBlank(message: 'Company name is required'),
new Length(min: 2, max: 100, minMessage: 'Company name must be at least {{ limit }} characters'),
],
'taxId' => [
new NotBlank(message: 'Tax ID is required'),
new Regex(
pattern: '/^[A-Z]{2}[0-9A-Z]{8,12}$/',
message: 'Tax ID must be in format: 2 letters followed by 8-12 alphanumeric characters (e.g., PL1234567890)',
),
],
default => [],
};
}
}
{% block formContent %}
<div class="grid">
<div>
{{ form_start(form, {
'attr': {
'hx-post': path(routePrefix),
'hx-target': '#form-wrapper',
'hx-swap': 'innerHTML',
'autocomplete': 'off',
'novalidate': 'novalidate',
}
}) }}
{% if not form.vars.valid and form.vars.submitted %}
<div id="form-error" class="alert-error">
<strong>Error:</strong> Please fix the validation errors below.
</div>
{% endif %}
<article>
<header>
<strong><kbd>1</kbd> Live Search / Autocomplete</strong>
</header>
<p><small>Type at least 2 characters to search users. Uses <code>hx-trigger</code> with delay and condition.</small></p>
<p><small>Try: <kbd>john</kbd> <kbd>jane</kbd> <kbd>alice</kbd> <kbd>bob</kbd> <kbd>@example</kbd></small></p>
<div class="field-relative">
{{ form_row(form.user) }}
<div id="user-results" class="user-results"></div>
</div>
<footer>
<small><strong>htmx options:</strong> get, trigger, target, indicator, params, on::before-request</small>
</footer>
</article>
<article>
<header>
<strong><kbd>2</kbd> Cascading Selects</strong>
</header>
<p><small>Select a country to load cities dynamically. Uses <code>hx-on::config-request</code> to modify the URL.</small></p>
<div class="grid">
<div>{{ form_row(form.country) }}</div>
<div>{{ form_row(form.city) }}</div>
</div>
<footer>
<small><strong>htmx options:</strong> get, trigger, target, on::config-request</small>
</footer>
</article>
<article>
<header>
<strong><kbd>3</kbd> Inline Validation</strong>
</header>
<p><small>Fields validate on blur. Server-side validation without full form submit.</small></p>
{{ form_row(form.email) }}
{{ form_row(form.username) }}
<footer>
<small><strong>htmx options:</strong> post, trigger, target, swap</small>
</footer>
</article>
<article>
<header>
<strong><kbd>4</kbd> Conditional Fields</strong>
</header>
<p><small>Select account type to show/hide additional fields. Uses <code>conditional</code> option from <code>ConditionalTypeExtension</code>.</small></p>
{{ form_row(form.accountType) }}
<div id="{{ form.business.vars.conditional.wrapper_id|default('business-fields') }}">
{{ block('businessFields') }}
</div>
<footer>
<small><strong>conditional:</strong> trigger, endpoint (auto-configures htmx)</small>
</footer>
</article>
<article>
<header>
<strong><kbd>5</kbd> Form Submit</strong>
</header>
<p><small>The entire form submits via htmx using form_start attributes.</small></p>
<p><small><ins>Info:</ins> On validation error, the page auto-scrolls to the error message via <code>triggerAfterSwap(['scrollTo' => '...'])</code> passed from the controller response.</small></p>
<hr>
{{ form_row(form.submit) }}
</article>
{{ form_end(form, {'render_rest': false}) }}
</div>
<div>
<article>
<header><strong>HtmxOptions Builder</strong></header>
<p><small>Use fluent builder API with <code>HtmxOptions</code>:</small></p>
<div class="code-example">
<pre><code>use Mdxpl\HtmxBundle\Form\Htmx\HtmxOptions;
use Mdxpl\HtmxBundle\Form\Htmx\Trigger\Trigger;
$builder->add('search', TextType::class, [
'htmx' => HtmxOptions::create()
->getRoute('app_search')
->trigger(Trigger::keyup()->changed()->delay(300))
->target('#results')
->indicator('#spinner'),
]);</code></pre>
</div>
<h4>Available Methods</h4>
<table>
<thead>
<tr><th>Method</th><th>Attribute</th></tr>
</thead>
<tbody>
<tr><td>get() / getRoute()</td><td>hx-get</td></tr>
<tr><td>post() / postRoute()</td><td>hx-post</td></tr>
<tr><td>trigger()</td><td>hx-trigger</td></tr>
<tr><td>target()</td><td>hx-target</td></tr>
<tr><td>swap()</td><td>hx-swap</td></tr>
<tr><td>indicator()</td><td>hx-indicator</td></tr>
<tr><td>include()</td><td>hx-include</td></tr>
<tr><td>vals()</td><td>hx-vals</td></tr>
<tr><td>confirm()</td><td>hx-confirm</td></tr>
<tr><td>onBeforeRequest()</td><td>hx-on::before-request</td></tr>
</tbody>
</table>
</article>
<article>
<header><strong>Trigger Builder</strong></header>
<p><small>Build complex triggers with <code>Trigger</code> class:</small></p>
<div class="code-example">
<pre><code>use Mdxpl\HtmxBundle\Form\Htmx\Trigger\Trigger;
// keyup changed delay:300ms
Trigger::keyup()->changed()->delay(300)
// blur changed delay:500ms
Trigger::blur()->changed()->delay(500)
// change (for selects)
Trigger::change()
// keyup[target.value.length >= 2]
Trigger::keyup()->condition('target.value.length >= 2')</code></pre>
</div>
</article>
<article>
<header><strong>Route with Placeholders</strong></header>
<p><small>Use placeholders for dynamic field values:</small></p>
<div class="code-example">
<pre><code>// Server-side: {name}, {id}, {full_name}
HtmxOptions::create()
->postRoute('app_validate', ['field' => '{name}'])
->target('#{id}-validation')
// Client-side: {value} (auto-generates JS)
HtmxOptions::create()
->getRoute('app_cities', ['country' => '{value}'])
->trigger(Trigger::change())</code></pre>
</div>
</article>
<article>
<header><strong>Conditional Fields</strong></header>
<p><small>Use <code>conditional</code> to show/hide fields:</small></p>
<div class="code-example">
<pre><code>$builder
->add('accountType', ChoiceType::class, [
'choices' => [
'Personal' => 'personal',
'Business' => 'business',
],
'expanded' => true,
])
->add('business', BusinessFieldsType::class, [
'conditional' => [
'trigger' => 'accountType',
'endpoint' => '/form/business-fields',
],
]);</code></pre>
</div>
</article>
</div>
</div>
{% endblock %}
{% block searchResults %}
{% if results is defined and results|length > 0 %}
<ul>
{% for user in results %}
<li>
<a href="#" onclick="document.querySelector('[name=\'form[user]\']').value = '{{ user.name }}'; this.closest('ul').remove(); return false;">
<strong>{{ user.name }}</strong><br>
<small>{{ user.email }}</small>
</a>
</li>
{% endfor %}
</ul>
{% elseif results is defined %}
<p><small>No users found</small></p>
{% endif %}
{% endblock %}
{% block citySelect %}
{{ form_row(cityField) }}
{% endblock %}
{% block fieldValidation %}
{% if errors is defined and errors|length > 0 %}
<div class="validation-message validation-error">
{{ errors|first }}
</div>
{% elseif isValid is defined and isValid %}
<div class="validation-message validation-success">
Valid
</div>
{% endif %}
{% endblock %}
{% block businessFields %}
{% set showBusiness = isBusiness is defined ? isBusiness : (form is defined and form.accountType is defined and form.accountType.vars.value == 'business') %}
{% set businessForm = businessForm is defined ? businessForm : (form is defined and form.business is defined ? form.business : null) %}
{% if showBusiness and businessForm %}
<div class="business-fields">
<p><strong>Business Account Fields</strong></p>
{{ form_row(businessForm.companyName) }}
{{ form_row(businessForm.taxId) }}
{{ form_row(businessForm.companyAddress) }}
</div>
{% elseif showBusiness %}
<div class="business-fields">
<p><strong>Business Account Fields</strong></p>
<p><small>Loading...</small></p>
</div>
{% else %}
<div class="business-fields">
<kbd>Personal</kbd> account selected - no additional fields required.
</div>
{% if form is defined and form.business is defined %}
{% do form.business.setRendered() %}
{% endif %}
{% endif %}
{% endblock %}
{% block submitSuccess %}
<article>
<header>
<strong>Form Submitted Successfully!</strong>
</header>
<h4>Submitted Data:</h4>
<div class="code-example">
<pre>{{ data|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
</div>
<footer>
<button class="outline"
hx-get="{{ path(routePrefix|default('app_advanced_form')) }}"
hx-target="#form-wrapper"
hx-swap="innerHTML">
Submit Another
</button>
</footer>
</article>
{% endblock %}