zenstruck/form-bundle
Symfony bundle that improves form rendering with ready-to-use Bootstrap form themes and handy form type extensions. It reduces custom form template boilerplate while keeping Symfony's native form system intact, making it easy to build cleaner, consistent forms across your application.
Before you install anything: check your Symfony version. This bundle's last release was October 2015, targets Symfony 2.x, and is archived — it will not install cleanly on Symfony 4+. Verify you're on a legacy app first:
php -v && grep -n '"symfony/symfony"' composer.json
If you're on Symfony 2.3–2.8 / 3.x, installation is the usual two steps:
composer require zenstruck/form-bundle
// app/AppKernel.php
$bundles = array(
// ...
new Zenstruck\Bundle\FormBundle\ZenstruckFormBundle(),
);
Then wire the Bootstrap form theme globally (or per-form later):
# app/config/config.yml
twig:
form:
resources:
- 'ZenstruckFormBundle:Form:bootstrap_3_layout.html.twig'
Where to look first. This bundle is small and the README is thinner than the code, so open the source before trusting any snippet you find online:
src/Zenstruck/Bundle/FormBundle/Form/Type/ — the AJAX, Tunnel and Select2 entity types. Option names are defined in setDefaultOptions()/configureOptions() and are the source of truth for your installed version.src/Zenstruck/Bundle/FormBundle/Form/Extension/ — the help type extension.src/.../Resources/views/Form/ — the exact theme filename to reference in twig.form.resources.src/.../Resources/public/js/ — the JS helpers; read them to learn the JSON payload shape the AJAX type expects, since the backend contract isn't documented.Publish the assets so the JS is reachable:
php app/console assets:install web --symlink
First use case. The two features worth adopting on day one are the help option (zero-risk, applies to every field) and the AJAX entity type (the one thing that solves a real problem — huge entity sets).
$builder
->add('email', 'email', array(
'help' => 'We never share your email address.',
))
->add('author', 'zenstruck_ajax_entity', array(
'class' => 'AppBundle:Author',
'property' => 'name',
'remote_route'=> 'author_autocomplete',
));
Confirm the option keys against AjaxEntityType.php in your vendor directory, then build the autocomplete action:
public function autocompleteAction(Request $request)
{
$q = $request->get('q');
$authors = $this->getDoctrine()->getRepository('AppBundle:Author')
->createQueryBuilder('a')
->where('a.name LIKE :q')
->setParameter('q', $q.'%')
->setMaxResults(20)
->getQuery()
->getResult();
return new JsonResponse(array_map(function ($a) {
return array('id' => $a->getId(), 'text' => $a->getName());
}, $authors));
}
Adopt the theme globally, override locally. Applying twig.form.resources in config keeps call sites clean. When one form needs different markup, use {% form_theme form 'AppBundle:Form:custom.html.twig' %} — themes are applied in order and later entries win, which is how you layer a custom layout over the bundle's Bootstrap one.
help as a copy layer, not a markup layer. The extension adds a help option to every field type via a form type extension, rendering it as a help-block after the widget. In practice this means:
'help' => null explicitly where the parent form would otherwise inherit a noisy default.AJAX entity type: keep rendering server-side, hydrate server-side. The pattern that holds up is: the widget submits an id, the bundle's data transformer converts that id back into an entity, and the AJAX endpoint only ever answers lookup queries. Never make the endpoint responsible for validation:
// Controller: only ever a lookup
->where('a.name LIKE :q')->setParameter('q', $q.'%')->setMaxResults(20)
// Form: standard entity-backed validation on the mapped field
->add('author', 'zenstruck_ajax_entity', array(
'class' => 'AppBundle:Author',
'remote_route' => 'author_autocomplete',
'required' => true,
))
Because the submitted value is an id, NotBlank/Valid constraints on the property keep working exactly as with a normal EntityType.
Tunnel type for hierarchical pickers. Use it where a user would otherwise face a single dropdown of hundreds of thousand-row hierarchies (country → region → city, category → subcategory, manufacturer → model). The value is that only the leaf is submitted while the intermediate selects are pure UI; you then resolve the leaf server-side. If the chain isn't strictly nested (e.g. tags), a plain AJAX type is the better fit.
Behind an alias, always. Wrap every bundle type in your own alias so migration is a one-file change later:
// AppBundle/Form/Type/AuthorAjaxType.php
class AuthorAjaxType extends AbstractType
{
public function getParent() { return 'zenstruck_ajax_entity'; }
public function getName() { return 'app_author_ajax'; }
}
Call sites use app_author_ajax. When you move to EntityType + choice_loader + UX Autocomplete, only this class changes.
Reinitialize JS on dynamic rows. The bundle's JS initializes the widget on document.ready. If you render the AJAX or Tunnel type inside a CollectionType and add rows client-side, you must re-run the initializer on the new node — otherwise the row silently degrades to an unenhanced select.
Testing. Two cheap tests pay for themselves:
// Functional: assert the endpoint contract the JS depends on
$client->request('GET', '/autocomplete/author?q=zen');
$this->assertJsonStringEqualsJsonString('[...]', $client->getResponse()->getContent());
// Unit: assert the transformer round-trips an id
$form = $this->factory->create('app_author_ajax');
$form->submit('42');
$this->assertSame($author42, $form->getData());
It is archived and will not survive a Symfony upgrade. The types implement the pre-3.0 FormTypeInterface (setDefaultOptions(), getName()), which Symfony 3 deprecated and 4 removed. composer require on a modern app fails or force-installs on old Symfony internals. If you inherit a project using it, budget the replacement rather than patching vendor code.
Bootstrap 3 only. The theme emits form-group / control-label / col-sm-*. Dropping it into a Bootstrap 4/5 layout produces visibly broken spacing and lost has-error states (BS4 moved validation to is-invalid/invalid-feedback). Either keep the whole app on BS3 or copy the template into your app and rewrite the classes — don't mix.
The help option is now native. Symfony 5.1 added help and help_html to the form component. On legacy code the bundle's version is fine, but if you also render standard Symfony themes you'll get duplicate help blocks, because the extension and the core theme both emit markup. When migrating, delete the extension and rename nothing else.
Select2 version matters more than the bundle version. The public JS targets Select2 3.x (id/text, ajax.results callback). Select2 4.x changed the transport and option names. If you're upgrading Select2 for unrelated reasons, you will be rewriting this integration — in which case go straight to TomSelect/Symfony UX Autocomplete instead of patching the bundle's JS.
Unpaginated lookups are a foot-gun. The AJAX type has no built-in paging. Always cap with setMaxResults() and, for large tables, an ORDER BY on an indexed column. A LIKE '%term%' without a limit on a 500k-row table is a production incident waiting for someone to open the dropdown.
The autocomplete endpoint is an unauthenticated read surface. It's a plain GET action; unless you add it to security.yml it leaks entity names to anonymous users. Treat it like a search API: scope the query to the current tenant/user, return only id + display field, and don't let the q parameter reach the DQL builder unchecked.
Debugging unknown option names. The README and your installed version often disagree. Use:
php app/console debug:form AppBundle:Author # Symfony 2.6+
or read setDefaultOptions() directly. Guessing at remote_route vs ajax_route burns time; the type's source is 30 lines long.
Bundle inheritance is the safe override point. Copy the template into app/Resources/ZenstruckFormBundle/views/Form/ (same relative path) rather than editing vendor/. Symfony's resource resolution picks up your copy and the change survives composer update — relevant because a 2015 package will never see an upstream fix.
Form theme ordering. With multiple twig.form.resources, the last one that defines a block wins, and the bundle's layout defines a lot of blocks. Put your app theme after it in config; put per-form form_theme overrides after that.
Migration path, in priority order. (1) Bootstrap theme → Symfony's built-in bootstrap_3_layout.html.twig (it's the same markup; verify block-by-block). (2) help → Symfony 5.1+ help option. (3) AJAX/Select2 entity type → EntityType with choice_loader for small sets, or Symfony UX Autocomplete + TomSelect for large ones. (4) Tunnel → chained EntityTypes driven by PRE_SUBMIT form events plus a small choice_loader; the bundle's version has no functional equivalent, so port the UX, not the code.
How can I help you explore Laravel packages today?