kissifrot/select2entity-bundle
A Symfony bundle that turns entity form fields into AJAX-powered Select2 inputs, letting users search and pick related entities from large datasets without loading them all. It provides a form type, autocomplete controller, and configurable endpoints for paginated, server-side entity lookup.
This is a Symfony bundle, not a Laravel package — no service provider, no facade, no config/select2entity.php. Treat it as a reference implementation you either install into a Symfony app or port in ~100 lines to Laravel.
If you're in a Symfony codebase:
composer require kissifrot/select2entity-bundleAppKernel::registerBundles() on Symfony 2/3, config/bundles.php on 4+ — Flex will not auto-register it).# config/routes.yaml
select2entity:
resource: "@Select2EntityBundle/Resources/config/routing.xml"
<select> with a searchable remote picker:
$builder->add('customer', Select2EntityType::class, [
'class' => Customer::class,
'remote_route' => 'select2entity_remote',
'primary_key' => 'id',
'text_property'=> 'name',
'allow_clear' => true,
'placeholder' => 'Search customers…',
]);
Where to look first: Form/Type/Select2EntityType.php (the full option list), Controller/Select2EntityController.php (the exact JSON/query contract), and Resources/views/Form/*.twig (how the JS is wired). Those three files are the bundle.
Single vs. multiple selection. Default is many-to-one. Set 'multiple' => true for many-to-many; the data transformer hydrates an ArrayCollection of ids from the posted array. Edit forms work if you pass the existing entity/collection as data — the widget renders the selected labels server-side so they survive the first paint.
The three-piece pattern (port this to Laravel). The bundle is really:
<select multiple> plus a JS init block.{"results":[{"id":1,"text":"Acme"}],"pagination":{"more":true}}.Laravel equivalent:
// routes/web.php
Route::get('/select2/customers', function (Request $request) {
$q = trim((string) $request->input('q'));
$rows = Customer::query()
->when($q !== '', fn ($qb) => $qb->where('name', 'like', "%{$q}%"))
->orderBy('name')->limit(11)->get();
return response()->json([
'results' => $rows->take(10)->map(fn ($c) => ['id' => $c->id, 'text' => $c->name]),
'pagination' => ['more' => $rows->count() > 10],
]);
})->middleware('auth');
<select name="customer_id" class="select2-remote" data-url="{{ route('select2.customers') }}">
@if($customer)<option value="{{ $customer->id }}" selected>{{ $customer->name }}</option>@endif
</select>
Custom endpoint instead of the bundled one. Always preferred: point remote_route at your own controller so you can apply tenant scoping, soft-delete filters, role checks, and a hard LIMIT. The default controller answers whatever class you hand it in the query string.
Server-side pre-filtering. For "customer must belong to this account," a custom action with a route parameter (/accounts/{account}/select2/customers) is cleaner than trying to pass context through the widget's options.
Validation. The transformer only resolves ids that exist; still validate with an entity/exists constraint afterward — a client can post any integer.
Select2EntityController reads the entity FQCN and text_property from query parameters. That is an information-disclosure and enumeration surface if the route is public. Either firewall it hard or expose only an explicit allow-list of class → property pairs, or roll your own action.composer.json constraints before installing; you may need a fork or to register the controller as an explicit service (no autowiring of old-style controllers). Do not plan a sprint around it working unmodified.q + page and expects a bare array; v4 sends term/q and expects {results, pagination}. If the dropdown spins forever, log the actual XHR payload — this is almost always the cause.text_property must be a real column or __toString(). Nested paths (customer.name) are typically not supported; add a getter or a Doctrine SELECT alias instead.pagination.more. Setting scroll: true without returning that key shows only the first page with no way to page further. Pair it with page_limit.LIKE %term% doesn't use an index. On big tables this is your bottleneck. Cap page_limit (10–20), add a trigram/full-text index, and debounce via delay: 250 so you aren't firing a query per keystroke.bin/console debug:router) → log the generated DQL/SQL in the controller → confirm JSON shape. Ninety percent of "it doesn't work" is step 1 or 3.cache/cache_timeout memoize XHRs in the browser; nothing is cached server-side, so a rename shows up stale until timeout.How can I help you explore Laravel packages today?