Here is what I ended up doing. This required a lot of trial and error and searching the EntityType class hierarchy and learning how form types really work. The hardest part is finding the source code and figuring out how to get from the PHP classes to Twig templates (which variables are available).
Here is what I did. This is not an ideal solution (it feels a bit hacked), but it works for my purposes. The idea is to expose the underlying Entity for my view so that I can get its properties.
The biggest problem is that the file property, which contains the file path, is hard-coded in the view. Anyway, I am posting the whole solution, as this may be useful to others. I am also open to criticism if anyone can find a better solution.
(spaces omitted)
Extended Entity Type
<?php class ExtendedEntityType extends EntityType { public function getParent() { return 'extended_choice'; } public function getName() { return 'extended_entity'; } }
Extended Choice Type (just needed to change addSubForms, but it's closed)
<?php class ExtendedChoiceType extends ChoiceType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { if (!$options['choice_list'] && !is_array($options['choices']) && !$options['choices'] instanceof \Traversable) { throw new FormException('Either the option "choices" or "choice_list" must be set.'); } if ($options['expanded']) { $this->addSubForms($builder, $options['choice_list']->getPreferredViews(), $options); $this->addSubForms($builder, $options['choice_list']->getRemainingViews(), $options); if ($options['multiple']) { $builder ->addViewTransformer(new ChoicesToBooleanArrayTransformer($options['choice_list'])) ->addEventSubscriber(new FixCheckboxInputListener($options['choice_list']), 10) ; } else { $builder ->addViewTransformer(new ChoiceToBooleanArrayTransformer($options['choice_list'])) ->addEventSubscriber(new FixRadioInputListener($options['choice_list']), 10) ; } } else { if ($options['multiple']) { $builder->addViewTransformer(new ChoicesToValuesTransformer($options['choice_list'])); } else { $builder->addViewTransformer(new ChoiceToValueTransformer($options['choice_list'])); } } if ($options['multiple'] && $options['by_reference']) { // Make sure the collection created during the client->norm // transformation is merged back into the original collection $builder->addEventSubscriber(new MergeCollectionListener(true, true)); } } /** * {@inheritdoc} */ public function getParent() { return 'choice'; } /** * {@inheritdoc} */ public function getName() { return 'extended_choice'; } /** * Adds the sub fields for an expanded choice field. * * @param FormBuilderInterface $builder The form builder. * @param array $choiceViews The choice view objects. * @param array $options The build options. */ private function addSubForms(FormBuilderInterface $builder, array $choiceViews, array $options) { foreach ($choiceViews as $i => $choiceView) { if (is_array($choiceView)) { // Flatten groups $this->addSubForms($builder, $choiceView, $options); } else { $choiceOpts = array( 'value' => $choiceView->value, // Expose more data 'label' => array( 'data' => $choiceView->data, 'label' => $choiceView->label, ), 'translation_domain' => $options['translation_domain'], ); if ($options['multiple']) { $choiceType = 'checkbox'; // The user can check 0 or more checkboxes. If required // is true, he is required to check all of them. $choiceOpts['required'] = false; } else { $choiceType = 'radio'; } $builder->add((string) $i, $choiceType, $choiceOpts); } } } }
Services
<service id="crolts_main.type.extended_choice" class="My\MainBundle\Form\Type\ExtendedChoiceType"> <tag name="form.type" alias="extended_choice" /> </service> <service id="crolts_main.type.extended_entity" class="My\MainBundle\Form\Type\ExtendedEntityType"> <tag name="form.type" alias="extended_entity" /> <argument type="service" id="doctrine" /> </service>
form_layout.html.twig
(this is based on a MopaBootStrapBundle, but the idea is the same. The difference is that MopaBootstrap wraps a <label> around a <radio> )
{% block extended_choice_widget %} {% spaceless %} {% if expanded %} {{ block('extended_choice_widget_expanded') }} {% else %} {# not being used, just default #} {{ block('choice_widget_collapsed') }} {% endif %} {% endspaceless %} {% endblock extended_choice_widget %} {% block extended_choice_widget_expanded %} {% spaceless %} <div {{ block('widget_container_attributes') }}> {% for child in form %} <label class="{{ (multiple ? 'checkbox' : 'radio') ~ (widget_type ? ' ' ~ widget_type : '') ~ (inline is defined and inline ? ' inline' : '') }}"> {{ form_widget(child, {'attr': {'class': attr.widget_class|default('')}}) }} {% if child.vars.label.data.file is defined %} <img src="{{ vich_uploader_asset(child.vars.label.data, 'file')}}" alt=""> {% endif %} {{ child.vars.label.label|trans({}, translation_domain) }} </label> {% endfor %} </div> {% endspaceless %} {% endblock extended_choice_widget_expanded %}
Using
<?php $builder->add('icon', 'extended_entity', array( 'class' => 'MyMainBundle:MenuIcon', 'property' => 'name', // this is still used in label.label 'expanded' => true, 'multiple' => false ));