ringlesoft/laravel-selectable
Laravel Selectable generates HTML select options directly from Laravel collections using a fluent, chainable API. Customize labels and values with strings or closures, set selected/disabled options, add data attributes, grouping, and export arrays for AJAX or SPAs.
composer require ringlesoft/laravel-selectable
There is no service provider config to publish, no config file, and no migrations. The package adds macros to Laravel's collection so the API is available anywhere a Collection is: controllers, view composers, Blade components, tests, and Tinker.
The fastest first use case is replacing a hand-written @foreach in a Blade form:
<select name="user_id" class="form-select">
{!! \App\Models\User::all()->toSelectOptions() !!}
</select>
Defaults are id for the value and name for the label, so a standard users table just works. The inline positional signature is toSelectOptions($label, $value, $selected, $disabled):
{!! \App\Models\User::all()->toSelectOptions('email', 'uuid', $user->uuid) !!}
toSelectable() fluent builder, toSelectItems()) cover essentially the whole surface area of the package.Selectable class in src/ — this is where withLabel, withValue, withSelected, withDisabled, withDataAttribute, withId, withClass, toSelectItems, and toSelectOptions live. Read the two to* methods first; they reveal exactly what is and is not escaped.toSelectable() on Collection, that helper is where the annotations come from.withId and closure data attributes. Anything older than 1.0.4 is missing methods referenced in the README.The package pays for itself the moment you have a model whose display name is not name, which is almost immediately:
<select name="assignee_id">
{!! $project->members()->get()->toSelectOptions(
label: fn ($u) => "{$u->first_name} {$u->last_name}",
value: 'id',
selected: $task->assignee_id
) !!}
</select>
That single call replaces the loop, the selected ternary, and the null-check for a missing assignee.
The README's inline User::all() examples are illustrative, and the README itself warns against them. In day-to-day code, build options in a controller, a Blade component, or a dedicated view model and pass the finished string down:
// app/Http/Controllers/TaskController.php
public function edit(Task $task)
{
$assignees = User::query()
->where('active', true)
->orderBy('first_name')
->get()
->toSelectOptions('name', 'id', $task->assignee_id);
return view('tasks.edit', compact('task', 'assignees'));
}
<select name="assignee_id" class="form-select">
{!! $assignees !!}
</select>
This keeps queries out of templates and makes the options cacheable/testable.
Because the package returns a raw HTML string, the cleanest integration is an anonymous Blade component that owns the escaping decision once:
{{-- resources/views/components/select.blade.php --}}
@props(['name', 'options', 'selected' => null, 'multiple' => false, 'placeholder' => null])
<select name="{{ $name }}" @if($multiple) multiple @endif {{ $attributes->merge(['class' => 'form-select']) }}>
@if($placeholder)
<option value="">{{ $placeholder }}</option>
@endif
{!! $options !!}
</select>
Now every form in the app renders selects identically and a future swap away from the package touches one file.
Reach for toSelectable() the moment you need two or more customizations:
$options = User::with('team')->get()
->toSelectable()
->withValue('uuid')
->withLabel(fn ($user) => "{$user->name} ({$user->team?->name})")
->withSelected($task->assignee_ids ?? [])
->withDisabled(fn ($user) => ! $user->active)
->withId(fn ($user) => "assignee-{$user->uuid}")
->withDataAttribute('team', fn ($user) => $user->team?->slug ?? '')
->withClass('form-option')
->toSelectOptions();
This is the workhorse pattern for admin panels: UUID values, composite labels, multi-select preselection, inactive users disabled rather than hidden, plus a data-team attribute for a client-side filter.
toSelectItems() exists precisely so you do not parse HTML on the frontend:
public function mount()
{
$this->users = User::all()->toSelectable()->toSelectItems();
}
The shape is stable and easy to consume:
[
[
'label' => 'Jane Doe',
'value' => '2f3a-...',
'selected' => false,
'disabled' => false,
'dataAttributes' => ['hidden' => false],
'classes' => ['form-option', 'custom'],
],
]
Two practical uses: (a) feeding a JS component that renders its own markup, and (b) server-side validation by deriving the allowed set:
$allowed = collect(User::all()->toSelectable()->toSelectItems())->pluck('value');
$request->validate([
'user_id' => ['required', Rule::in($allowed)],
]);
groupByGrouping is delegated to Laravel's own groupBy, and the package renders <optgroup> from the result:
$options = User::all()
->toSelectable()
->groupBy(fn ($user) => $user->department)
->toSelectOptions();
Call groupBy on the Selectable object, not on the collection before toSelectable(). Grouping first gives you a collection of collections whose items no longer answer to withLabel/withValue accessors, and the labels fall back to whatever the inner collection happens to serialize as.
For config arrays, enums mapped to labels, or raw query results:
// flat list, label === value
collect(['Draft', 'Published', 'Archived'])->toSelectOptions();
// associative, key = value, entry = label
collect(['draft' => 'Draft', 'published' => 'Published'])->toSelectOptions();
// array of arrays, pick the fields
collect($rows)->toSelectable()->withValue('number')->withLabel('name')->toSelectOptions();
This is a good fit for status filters and for config()-driven dropdowns where spinning up a model is overkill.
Because toSelectOptions() returns a string, assertions are straightforward and fast:
it('preselects the assignee', function () {
$task = Task::factory()->for(User::factory()->state(['name' => 'Jane']))->create();
$html = User::all()->toSelectOptions('name', 'id', $task->assignee_id);
expect($html)->toContain('selected');
});
toSelectItems() is even better for logic tests — assert on selected/disabled booleans instead of grepping HTML, then keep one or two rendering smoke tests.
The package composes with collections rather than replacing them. Do ordering, filtering, and eager loading in the query; use the package only for presentation.
User::query()->orderBy('name')->get() // sorting in SQL
->toSelectable()
->withLabel('name') // presentation only
->toSelectOptions();
Avoid ->sortBy(fn ($u) => ...) on the collection for large sets — it is PHP-side and will cost you more than an ORDER BY.
toSelectOptions() returns a ready-made HTML fragment, so it must be printed with {!! !!}. If you print it with {{ }}, users see literal <option> tags on the page. Conversely, if any label is derived from user-controlled data (a name field, a title, a team name), it is your responsibility to escape it before it reaches the builder:
->withLabel(fn ($u) => e($u->display_name))
Audit this on any model whose display name can contain <, &, or quotes. A stray apostrophe in a label is harmless, but a stored <script> in a label field is not.
withSelected comparisons are effectively strictThe selected value must match the generated value. withValue('uuid') plus withSelected($user->id) silently selects nothing, and an integer 1 passed against a string '1' value may not match. Normalize to strings when in doubt:
->withSelected((string) $task->assignee_id)
For multi-selects, always pass an array (or a closure returning bool). Passing a single scalar against a multiple select is a common cause of "preselection doesn't work".
= not ===->withDisabled(fn($item) => $item->status = 'inactive') // BUG in the README
That assigns 'inactive' to every model and returns a truthy string, so every option is disabled. Write the comparison:
->withDisabled(fn ($item) => $item->status === 'inactive')
Worth flagging in code review, because it is copied verbatim from the docs.
If your model has no name attribute (or you forgot withLabel), you get blank labels rather than an exception. Same for id on non-Eloquent collections — array collections need withValue() explicitly. If a dropdown renders as a column of empty options, check the label field before you check anything else.
withDataAttribute('hidden', fn ($i) => $i->status !== 'active') renders data-hidden="1" or data-hidden="" depending on how PHP coerces the boolean. If your JS reads dataset.hidden === 'true', cast explicitly in the closure:
->withDataAttribute('hidden', fn ($i) => $i->status !== 'active' ? 'true' : 'false')
withId and toSelectItems() are not perfectly symmetricwithId was added in 1.0.4, later than the documented toSelectItems() structure. Do not assume the item array carries an id key. If your SPA needs per-option IDs, verify against the installed version's source or keep the ID in a dataAttributes entry instead.
->get() firsttoSelectable() is a collection macro. On a query builder you must materialize:
User::where('active', true)->toSelectOptions(); // ✗ Builder, not Collection
User::where('active', true)->get()->toSelectOptions(); // ✓
The error message on the failing case is unhelpful — "method does not exist" on an Eloquent Builder — so remember it is a Collection concern.
There is no chunking or lazy rendering: you get one string per call. For a few hundred options this is fine and demonstrably faster than a Blade loop (option rendering was optimized in 1.0.4). For thousands, use a searchable AJAX select instead, and use toSelectItems() or a paginated endpoint rather than shipping a 5,000-option string on every page load.
Country lists, categories, and role lists do not change per request:
$options = Cache::rememberForever('options.roles', fn () => Role::all()->toSelectOptions());
The string is deterministic given the same data, so it caches cleanly. Just remember to bust the key on save — a model observer is usually the least painful hook.
Every call site repeats 'name', 'id', and class names. If that bothers you, define your own macro once in a service provider and standardize:
Collection::macro('toAppSelect', function (mixed $selected = null) {
return $this->toSelectable()
->withLabel('name')
->withSelected($selected)
->withClass('form-option')
->toSelectOptions();
});
Macros are the intended extension point here — the package itself is built the same way.
Five stars and zero dependents means the bus factor is real. The API is narrow enough that the honest risk assessment is: keep usage behind a Blade component or a macro (Patterns 2 and 1), and if you ever need to remove the package you can reimplement toSelectOptions in about thirty lines. Avoid scattering ->toSelectable()->withX() chains across fifty templates; that is what makes a small dependency expensive to leave.
When an option list looks wrong, check in this order:
Collection (did you ->get())?selected the same type as the generated value?{!! !!}?Four out of five of these are answered by dumping ->toSelectable()->toSelectItems() first — it shows you exactly what the package thinks each option is before HTML is involved.
How can I help you explore Laravel packages today?