06/24/2014
Symfony collection prototype: formatting it in Twig
The short version
The problem
Symfony's collection prototype hands Twig a blob of escaped HTML, so the fields inside it cannot be laid out individually.
The approach
Render the prototype through your own template instead of taking the default string, so each child field is addressable.
The result
A dynamically added row that carries the same markup and classes as the rows rendered on page load.
Let’s say you are creating a form in Symfony that has some dynamic fields that you need to allow the user to add and remove a subset of options and fields from within the form. Enabling dynamic forms in Symfony is a bit confusing to those just getting their feet wet. Here’s a hint: You have to use data prototypes and a little bit of Javascript. I prefer JQuery, but it doesn’t matter as long as the behavior ends up with the same result of “cloning” the prototype HTML into the proper place on the form with a serialized incrementation of its field name values.
Symfony relies on Javascript for dynamic forms?! Say it isn’t so, Fabien!
Well, every framework and those home rolled models all employ the same basic tactic where parts of a form that may “grow” need to first be hidden from the user and serialized as the user adds new fields. Think of a blogger adding multiple “tags” to a post, or someone needing to leave multiple notes in someone’s medical record.
Dynamic forms are a client-side job: Symfony renders a hidden
data-prototype attribute holding the markup for one row, and JavaScript
unescapes it and puts a copy where the new row belongs. Nothing about that
has changed, and it is still how CollectionType is meant to be used.
If you need to reach inside the prototype markup rather than take it as one blob -- because plain CSS is not enough to lay out the label and the field, or you simply want more control over the markup -- this is the pattern for getting at those elements from Twig. Just replace “blogPost” with the name of the form and “tagName” with the name of the form field.
<div id="clone-tag-form">
<div class="formatting-tag-couldnt-do-before">
{{ form_label(form.blogPost.vars.prototype.tagName) }}
</div>
{{ form_widget(form.blogPost.vars.prototype.tagName) }}
</div>
Reaching in like that gives you control over the markup and hands you the
rest of the job: every field name inside the prototype contains the
placeholder __name__ — lowercase, and configurable through
CollectionType's prototype_name option — which your JavaScript has to
replace with an incrementing index before the row is added. Miss one and
the form handler sees two fields with the same name and quietly keeps one
of them.
There is a second approach, which renders the prototype through a template of its own. It is more moving parts and it is worth it as soon as the row markup is more than a label and an input:
Idea adapted from Stack Overflow:
The idea is simply to render the collection items through a Twig template, so you can customize the prototype that will be placed in your
data-prototype="..."
tag as if it was a normal form.
In yourMainForm.html.twig:
<div id="collectionCont" data-prototype="
{% filter escape %}
{{ include('form/prototype.html.twig', {
'form': form.myForm.vars.prototype
}) }}
{% endfilter %}
"></div>
And in templates/form/prototype.html.twig:
<div>
{# customize as needed #}
{{ form_label(form.field1) }}
{{ form_widget(form.field1) }}
{{ form_label(form.field2) }}
{{ form_widget(form.field2) }}
</div>
Questions this keeps raising
What is __name__ in the prototype markup?
The placeholder Symfony puts where the row index belongs. Your JavaScript replaces every occurrence with an incrementing number before inserting the row, so the field names come back as an indexed collection. It is lowercase, and CollectionType's prototype_name option changes it if it collides with your own content.
Why do my added rows not arrive in the controller?
Almost always an unreplaced __name__ , so two rows share a field name and the handler keeps one. Check the rendered HTML of an added row before looking anywhere else. The other cause is allow_add not being set on the CollectionType, which makes Symfony reject indexes it did not render itself.
Can I lay out the prototype fields individually?
Yes - form.myForm.vars.prototype gives you the prototype as a form view, so form_label and form_widget work on its children exactly as they do on a rendered row. That is what the first example here does.
Is MyBundle:MyViewsDir:template.html.twig still valid?
No. The bundle notation with colons was removed in Symfony 4.0. Use a path relative to templates/, such as form/prototype.html.twig, or @BundleName/path for a template that genuinely lives inside a bundle.