# WithSearchableChoices A small Livewire trait that removes the repeated `search{X}` / `selectAll{X}` / `clear{X}` boilerplate that shows up every time you wire a `` multi-select to a service-backed list (roles, permissions, users, tags, etc.). Instead of writing three full methods per entity, you register one config entry per field and keep three one-line delegates. All the real logic lives once, in the trait. ## Requirements - PHP 8.2+ (uses a `readonly` class with constructor promotion; first-class callable syntax `(...)` is PHP 8.1+, so this also runs fine on 8.1 if you swap those for closures) - Laravel (uses the `data_set()` helper) - Livewire 3 or 4 — nothing here is version-specific. Traits operate at the PHP class level, so this works whether your component is a classic two-file component or a Livewire 4 single-file `⚡component.blade.php`. ## Files ### `app/Livewire/Concerns/ChoiceField.php` Describes a single searchable field: how to search it, how to select all of it, where its selected IDs live on the form object, and an optional side effect to run after a change. ```php > */ public array $choicesSearchable = []; /** @return array */ abstract protected function choiceFields(): array; public function searchChoice(string $key, string $value = ''): void { $field = $this->resolveChoiceField($key); $this->choicesSearchable[$key] = ($field->search)($value); } public function selectAllChoice(string $key): void { $field = $this->resolveChoiceField($key); data_set($this, $field->formPath, ($field->allIds)()); $this->searchChoice($key); $field->after?->__invoke(); } public function clearChoice(string $key): void { $field = $this->resolveChoiceField($key); data_set($this, $field->formPath, []); $field->after?->__invoke(); } protected function resolveChoiceField(string $key): ChoiceField { return $this->choiceFields()[$key] ?? throw new InvalidArgumentException("Unknown choice field [{$key}]."); } } ``` Note that `choiceFields()` is a **method**, not a property. Closures can't be stored on a public Livewire property (they're not JSON-serializable for the wire snapshot), so keeping the config behind a method means it's rebuilt fresh each request and never touches Livewire's hydration/dehydration cycle. ## Usage ### 1. Use the trait and register your fields ```php use App\Livewire\Concerns\{WithSearchableChoices, ChoiceField}; class RoleAssignmentForm extends Component { use WithSearchableChoices; protected function choiceFields(): array { return [ 'roles' => new ChoiceField( search: fn (string $value): array => $this->roleService->searchRolesForSelect($value)->toArray(), allIds: $this->roleService->getAllRoleIds(...), // <-- '...' is important here, as this make the closure callable formPath: 'form.roleIds', // <-- this should be the livewire form object's property path in dot notation after: $this->hydratePermissionInForm(...), // <-- optional side effect, that will be run after a change ), ]; } public function searchRoles(string $value = ''): void { $this->searchChoice('roles', $value); } public function selectAllRoles(): void { $this->selectAllChoice('roles'); } public function clearRoles(): void { $this->clearChoice('roles'); } } ``` The three delegate methods exist because Livewire requires `wire:click`/action targets to be real, declared public methods — it won't fall through to `__call()`. Keeping them means your Blade markup and method names don't change at all; they just forward into the trait. ### 2. Update the Blade options binding Everything stays the same except where `:options` reads from, since there's now one shared `$choicesSearchable` array keyed by field name instead of a separate property per entity: ```blade ``` ### 3. Add another field (e.g. permissions) ```php protected function choiceFields(): array { return [ 'roles' => new ChoiceField(/* ... */), 'permissions' => new ChoiceField( search: fn (string $value): array => $this->permissionService->searchPermissionsForSelect($value)->toArray(), allIds: $this->permissionService->getAllPermissionIds(...), formPath: 'form.permissionIds', ), ]; } public function searchPermissions(string $value = ''): void { $this->searchChoice('permissions', $value); } public function selectAllPermissions(): void { $this->selectAllChoice('permissions'); } public function clearPermissions(): void { $this->clearChoice('permissions'); } ``` ```blade ``` ## Notes & caveats - `formPath` is resolved with Laravel's `data_set()`, so it works against any nested object/array path on the component (`form.roleIds`, `filters.tags`, etc.), not just a single level. - The `after` callback runs on **select all** and **clear**, matching the original behavior where selecting/clearing roles re-hydrates dependent permission state. Leave it `null` for fields with no side effects. - This assumes `` interpolates `search-function` / `select-all-action` / `clear-action` directly into `wire:click` / `wire:keyup`-style attributes as plain method names. If that component does anything more elaborate internally (e.g. wraps the search call with extra arguments), double-check the generated action string against this trait's method signatures.