Skip to content

Commit 1db92e7

Browse files
committed
Add guide for creating custom choice field types
1 parent 1343d32 commit 1db92e7

File tree

2 files changed

+290
-0
lines changed

2 files changed

+290
-0
lines changed

form/create_custom_choice_type.rst

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
.. index::
2+
single: Form; Custom choice type
3+
4+
How to Create a Custom Choice Field Type
5+
=========================================
6+
7+
The :doc:`ChoiceType </reference/forms/types/choice>` is one of the most
8+
powerful form types in Symfony. When you need to reuse the same set of choices
9+
across multiple forms, creating a custom choice type avoids repetition and
10+
centralizes your logic::
11+
12+
use App\Form\Type\CategoryType;
13+
14+
$builder->add('category', CategoryType::class);
15+
16+
Static Choices
17+
--------------
18+
19+
When the list of choices is known ahead of time, create a type that inherits
20+
from ``ChoiceType`` and sets the ``choices`` option::
21+
22+
// src/Form/Type/CategoryType.php
23+
namespace App\Form\Type;
24+
25+
use Symfony\Component\Form\AbstractType;
26+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
27+
use Symfony\Component\OptionsResolver\OptionsResolver;
28+
29+
class CategoryType extends AbstractType
30+
{
31+
public function configureOptions(OptionsResolver $resolver): void
32+
{
33+
$resolver->setDefaults([
34+
'choices' => [
35+
'Electronics' => 'electronics',
36+
'Books' => 'books',
37+
'Clothing' => 'clothing',
38+
],
39+
]);
40+
}
41+
42+
public function getParent(): string
43+
{
44+
return ChoiceType::class;
45+
}
46+
}
47+
48+
The ``getParent()`` method tells Symfony to use ``ChoiceType`` as the base,
49+
inheriting all its options (``expanded``, ``multiple``, ``placeholder``, etc.)
50+
and rendering logic.
51+
52+
.. tip::
53+
54+
Read the :doc:`/form/create_custom_field_type` article for more details
55+
about ``getParent()`` and other methods of the form type interface.
56+
57+
Lazy-Loading Choices
58+
--------------------
59+
60+
When loading choices is expensive (e.g. querying a database or calling an
61+
external service), you can defer the loading until the choices are actually
62+
needed by using the ``choice_loader`` option. This avoids the overhead when
63+
the form is submitted with valid data and no rendering is required::
64+
65+
// src/Form/Type/CategoryType.php
66+
namespace App\Form\Type;
67+
68+
use App\Repository\CategoryRepository;
69+
use Symfony\Component\Form\AbstractType;
70+
use Symfony\Component\Form\ChoiceList\ChoiceList;
71+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
72+
use Symfony\Component\OptionsResolver\OptionsResolver;
73+
74+
class CategoryType extends AbstractType
75+
{
76+
public function __construct(
77+
private CategoryRepository $categoryRepository,
78+
) {
79+
}
80+
81+
public function configureOptions(OptionsResolver $resolver): void
82+
{
83+
$resolver->setDefaults([
84+
'choice_loader' => ChoiceList::lazy($this, function (): array {
85+
return $this->categoryRepository->findAllAsChoices();
86+
}),
87+
]);
88+
}
89+
90+
public function getParent(): string
91+
{
92+
return ChoiceType::class;
93+
}
94+
}
95+
96+
The :method:`Symfony\\Component\\Form\\ChoiceList\\ChoiceList::lazy` method
97+
wraps the callback in a
98+
:class:`Symfony\\Component\\Form\\ChoiceList\\Loader\\CallbackChoiceLoader`
99+
and caches the result. The first argument (``$this``) identifies the type for
100+
caching purposes.
101+
102+
When the loaded choices depend on other form options, pass a ``$vary`` argument
103+
to ensure a separate cache entry per combination::
104+
105+
use Symfony\Component\Form\ChoiceList\ChoiceList;
106+
use Symfony\Component\OptionsResolver\Options;
107+
use Symfony\Component\OptionsResolver\OptionsResolver;
108+
109+
public function configureOptions(OptionsResolver $resolver): void
110+
{
111+
$resolver->setDefaults([
112+
'is_active' => true,
113+
'choice_loader' => function (Options $options) {
114+
$isActive = $options['is_active'];
115+
116+
return ChoiceList::lazy($this, function () use ($isActive): array {
117+
return $this->categoryRepository->findByActive($isActive);
118+
}, [$isActive]);
119+
},
120+
]);
121+
}
122+
123+
Creating a Custom Choice Loader
124+
--------------------------------
125+
126+
When the loading logic is too complex for a simple callback, implement a
127+
dedicated choice loader. The easiest way is to extend
128+
:class:`Symfony\\Component\\Form\\ChoiceList\\Loader\\AbstractChoiceLoader`,
129+
which handles caching and avoids loading choices unnecessarily (e.g. when the
130+
form is submitted empty)::
131+
132+
// src/Form/ChoiceList/ApiCategoryLoader.php
133+
namespace App\Form\ChoiceList;
134+
135+
use App\Service\CategoryApiClient;
136+
use Symfony\Component\Form\ChoiceList\Loader\AbstractChoiceLoader;
137+
138+
class ApiCategoryLoader extends AbstractChoiceLoader
139+
{
140+
public function __construct(
141+
private CategoryApiClient $api,
142+
private string $locale,
143+
) {
144+
}
145+
146+
protected function loadChoices(): iterable
147+
{
148+
// called when the full list is needed (e.g. rendering the form)
149+
return $this->api->fetchCategories($this->locale);
150+
}
151+
}
152+
153+
The ``loadChoices()`` method must return an iterable. Keys are used as labels
154+
unless the :ref:`choice_label <reference-form-choice-label>` option is set.
155+
You can also return grouped choices by using nested arrays where keys are group
156+
names.
157+
158+
For better performance on form submission, you can override two additional
159+
methods:
160+
161+
``doLoadChoicesForValues(array $values)``
162+
Called when the form is submitted. You can load only the choices matching
163+
the submitted values instead of the full list.
164+
165+
``doLoadValuesForChoices(array $choices)``
166+
Returns the string values for the given choices. This is an alternative to
167+
the :ref:`choice_value <reference-form-choice-value>` option.
168+
169+
::
170+
171+
protected function doLoadChoicesForValues(array $values): array
172+
{
173+
// only load the submitted categories instead of all of them
174+
return $this->api->fetchCategoriesByIds($values, $this->locale);
175+
}
176+
177+
Then use the custom loader in your form type with
178+
:method:`Symfony\\Component\\Form\\ChoiceList\\ChoiceList::loader` for proper
179+
caching::
180+
181+
// src/Form/Type/CategoryType.php
182+
namespace App\Form\Type;
183+
184+
use App\Form\ChoiceList\ApiCategoryLoader;
185+
use App\Service\CategoryApiClient;
186+
use Symfony\Component\Form\AbstractType;
187+
use Symfony\Component\Form\ChoiceList\ChoiceList;
188+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
189+
use Symfony\Component\OptionsResolver\Options;
190+
use Symfony\Component\OptionsResolver\OptionsResolver;
191+
192+
class CategoryType extends AbstractType
193+
{
194+
public function __construct(
195+
private CategoryApiClient $api,
196+
) {
197+
}
198+
199+
public function configureOptions(OptionsResolver $resolver): void
200+
{
201+
$resolver->setDefaults([
202+
'locale' => 'en',
203+
'choice_loader' => function (Options $options) {
204+
return ChoiceList::loader(
205+
$this,
206+
new ApiCategoryLoader($this->api, $options['locale']),
207+
[$options['locale']]
208+
);
209+
},
210+
]);
211+
}
212+
213+
public function getParent(): string
214+
{
215+
return ChoiceType::class;
216+
}
217+
}
218+
219+
Using ``choice_lazy`` for Large Datasets
220+
-----------------------------------------
221+
222+
When dealing with a very large number of choices (e.g. thousands of users),
223+
rendering them all in a ``<select>`` element is impractical. Set the
224+
``choice_lazy`` option to ``true`` to only load the choices that are preset as
225+
default values or submitted by the user::
226+
227+
$builder->add('user', CategoryType::class, [
228+
'choice_lazy' => true,
229+
]);
230+
231+
The form will not render the full list of choices. You are responsible for
232+
providing a JavaScript-based UI (e.g. an autocomplete widget) that lets users
233+
search and select choices dynamically.
234+
235+
.. versionadded:: 7.2
236+
237+
The ``choice_lazy`` option was introduced in Symfony 7.2.
238+
239+
Reusing EntityType Choices
240+
--------------------------
241+
242+
When your choices come from Doctrine entities, extend
243+
:doc:`EntityType </reference/forms/types/entity>` instead of ``ChoiceType``::
244+
245+
// src/Form/Type/CategoryType.php
246+
namespace App\Form\Type;
247+
248+
use App\Entity\Category;
249+
use App\Repository\CategoryRepository;
250+
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
251+
use Symfony\Component\Form\AbstractType;
252+
use Symfony\Component\OptionsResolver\Options;
253+
use Symfony\Component\OptionsResolver\OptionsResolver;
254+
255+
class CategoryType extends AbstractType
256+
{
257+
public function configureOptions(OptionsResolver $resolver): void
258+
{
259+
$resolver->setDefaults([
260+
'class' => Category::class,
261+
'only_active' => true,
262+
'query_builder' => function (Options $options) {
263+
$onlyActive = $options['only_active'];
264+
265+
return function (CategoryRepository $repository) use ($onlyActive) {
266+
$qb = $repository->createQueryBuilder('c')
267+
->orderBy('c.name', 'ASC');
268+
269+
if ($onlyActive) {
270+
$qb->andWhere('c.active = :active')
271+
->setParameter('active', true);
272+
}
273+
274+
return $qb;
275+
};
276+
},
277+
]);
278+
279+
$resolver->setAllowedTypes('only_active', 'bool');
280+
}
281+
282+
public function getParent(): string
283+
{
284+
return EntityType::class;
285+
}
286+
}
287+
288+
This inherits all ``EntityType`` and ``ChoiceType`` options, so you can use
289+
``expanded``, ``multiple``, ``choice_label``, etc.

forms.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,6 +1710,7 @@ Advanced Features:
17101710
/controller/upload_file
17111711
/security/csrf
17121712
/form/create_custom_field_type
1713+
/form/create_custom_choice_type
17131714
/form/data_transformers
17141715
/form/data_mappers
17151716
/form/create_form_type_extension

0 commit comments

Comments
 (0)