Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Livewire Async Select Laravel Package

drpshtiwan/livewire-async-select

A lightweight async select component for Laravel Livewire and Alpine.js, offering a modern Select2 alternative with dynamic option loading, debounced search, multi-select chips, Tailwind styling, custom slots, and full wire:model binding.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up in four steps.

composer require drpshtiwan/livewire-async-select
php artisan vendor:publish --tag=async-select-assets

Then three directives in your layout — the third one is the one people forget:

<head>
    @asyncSelectStyles
    @livewireStyles
</head>
<body>
    {{ $slot }}
    @livewireScripts
    @stack('scripts')   {{-- REQUIRED: component ships its JS here --}}
</body>

Build the first use case in ~10 minutes — a searchable user picker on an existing form. The endpoint is a thin closure returning {"data": [...]}:

// routes/web.php
Route::middleware(['async-auth'])->get('/api/users/search', function (Request $request) {
    return response()->json([
        'data' => User::query()
            ->where('name', 'like', "%{$request->get('search')}%")
            ->limit(20)
            ->get(['id', 'name', 'email']),
    ]);
});
<livewire:async-select
    name="user_id"
    wire:model="selectedUser"
    endpoint="/api/users/search"
    placeholder="Search users..."
/>

Where to look first when you have a question. The README is intentionally shallow — it points at https://livewire-select.thejano.com/. Go straight to the docs' api.md page for prop names and defaults, and troubleshooting.md before touching GitHub issues; the package is young enough (zero dependents) that you will often be the first person to hit a problem, and the docs cover the known slot/Livewire-4 edge cases. Watch the seven-minute demo video — it shows the interaction model faster than reading props.

Prerequisites worth checking before you commit: PHP 8.2+, Laravel 11/12/13, Livewire 3.3+ or 4.x. If you are still on Livewire 2, stop here.

Decide early: does this replace Select2 everywhere, or is it one field? Start with one field. The layout directives are global, but adoption should be incremental.

Implementation Patterns

Remote vs. local mode — pick per field, not per project

Remote (endpoint="/api/...") is for unbounded sets: users, customers, products, any table that will exceed a few hundred rows. Local (:options="$users") is for small, already-in-memory sets where you want the search/chip UI without an HTTP round trip:

<livewire:async-select wire:model="roleId" :options="$roles" :searchable="true" />

Rule of thumb: if your Livewire component already loaded the collection for something else, pass it as :options and skip the endpoint entirely — it eliminates a class of auth and CORS debugging.

Seeding selected labels without an API call

The single most useful pattern for edit forms. Without it, a form with a pre-selected user_id = 5 renders an empty chip until the dropdown is opened. value-labels renders the label immediately, zero requests:

<livewire:async-select
    wire:model="categoryId"
    endpoint="/api/categories"
    :value-labels="[3 => 'Web Development']"
/>

Build this map in your component's mount() from the already-loaded relationship:

public function mount(Post $post)
{
    $this->categoryId = $post->category_id;
    $this->categoryLabels = $post->category ? [$post->category->id => $post->category->name] : [];
}

Since 1.1.0 the component also auto-reads the wire:model property's value as the default — you no longer pass :value.

Multiple selection and limits

<livewire:async-select
    wire:model="selectedTags"
    endpoint="/api/tags/search"
    :multiple="true"
    :max-selections="5"
/>

Bind to an array property. Chips render per selection; max-selections blocks further adds client-side.

Custom slots — the differentiator

Slots are where this beats Select2 for real work, because you can show role, sku, avatar or email inline. Option payload fields beyond id/label are preserved by normalization (3.0.0), so anything your endpoint returns is reachable:

<livewire:async-select :options="$users" wire:model="selectedUser">
    <livewire:slot name="slot">
        <div class="flex flex-col">
            <span>{{ $option['label'] }}</span>
            <span class="text-xs text-gray-500">{{ $option['email'] }}</span>
        </div>
    </livewire:slot>

    <livewire:slot name="selectedSlot">
        <strong>{{ $option['label'] }}</strong>
    </livewire:slot>
</livewire:async-select>

Available variables: option slot gets $option, $isSelected, $isDisabled, $multiple; selected slot gets $option. Note the syntax — this is Livewire 3.3+/4.x named slots, not @slot. Callable slots still work as a backwards-compatible fallback if you need them.

The "add new" suffix button

A frequent day-to-day need: a user picker that also lets you create a user. The suffix button auto-closes the dropdown, which is exactly what you want before opening a modal:

<livewire:async-select
    wire:model="mediaId"
    endpoint="/api/media/search"
    :suffix-button="true"
    suffix-button-action="showAddMediaModal"
/>

Then dispatch your own Livewire event or $this->dispatch('open-modal', ...) from the action. Wire the modal's save handler to re-fetch or push into value-labels so the new record appears selected without a reload.

Grouped options

Container-style input (label + options) is normalized — useful for optgroups like "Departments → Teams". Emit that shape from your endpoint and the component handles the nesting.

Auth: use the shipped middleware, don't hand-roll

The async-auth middleware behaves exactly like auth but also accepts internally minted signed tokens (with replay protection) when X-Internal-User is present. Three deployment shapes:

Route::middleware(['async-auth'])->get('/api/search', ...);              // default web guard
Route::middleware(['async-auth:sanctum'])->get('/api/search', ...);      // Sanctum / token
Route::middleware(['web', 'async-auth:web,persist'])->get('/api/search', ...); // session persistence

Use persist when the endpoint must see the logged-in session (tenant scoping, auth()->user() inside the closure). Use plain async-auth for read-only public-ish lookups. Do not invent your own X-User-Id header scheme — you will bypass the replay protection and re-open the exact hole the middleware exists to close.

Integration tips

  • Keep the endpoint dumb. Accept search, apply a limit, return {data: [...]}. Do not put pagination, filters or business rules there — the component only sends search text.
  • Scope by tenant in the endpoint, never in the Blade tag. The endpoint is the trust boundary.
  • Index what you search. like '%term%' on a 500k-row table is fine at demo scale and awful in production; use whereFullText or a name prefix index once the dataset grows.
  • Wrap it. For a team, create your own <x-user-select> Blade component that hardcodes the endpoint, value-labels wiring and slot markup. When you need to pin, patch or swap the package, you change one file instead of forty.

Gotchas and Tips

@stack('scripts') is not optional. The most common "component renders but does nothing" report is a layout missing this directive. The JS is pushed to that stack; without it you get a static, non-interactive box. Check this before anything else.

Re-publish assets on upgrade. php artisan vendor:publish --tag=async-select-assets --force after every composer update. The CSS lives in a published file, not in your Tailwind build — a stale copy means new las- classes silently do nothing.

Do not let Tailwind purge the las- classes. They exist only in the published vendor CSS, not in your source, so your Tailwind config never sees them. That is by design (it's the no-style-conflict guarantee) — but if you run a CSS pipeline that scans and prunes vendor files, exempt the async-select asset path.

Pin the version, at least initially. 112 stars, zero dependents, and a 3.0.0 release that rewrote slot rendering and selection validation. Use "drpshtiwan/livewire-async-select": "^3.3" and read the changelog before minor bumps. The 3.x line fixed a genuine Livewire 4 regression — anything below 3.0.0 will show blank option rows and fragment markers in the dropdown on Livewire 4.

Blank option rows / stray HTML comments in the dropdown = the Livewire 4 slot-rendering regression. Upgrade to ≥3.0.0. If you are stuck on an older version, fall back to callable slots instead of named slots.

Local mode rejects unknown values when tags are disabled. This is correct behaviour and a common surprise: if wire:model holds an ID that isn't in :options, the selection is dropped on hydrate. In local mode, always populate :options with the full valid set — or use value-labels plus an endpoint for remote mode. Guard against a stale selectedId from a deleted record.

Selected chip disappears on edit forms → you forgot value-labels (remote mode) or the option isn't in the local collection. There is no lookup-by-id fetch; the component only knows labels you give it or options it has loaded.

Keep option keys intact. The 3.0.0 fix switched selection merging from array_merge to array_replace precisely because numeric keys were being reindexed and selections silently lost. If you build option arrays in PHP, use string id values or ensure keys are the actual record IDs — don't array_values() them.

Custom slot shows nothing for a field → check normalization preserved it. Extra fields (role, sku) are kept as of 3.0.0, but if your endpoint returns the row under a nested key (e.g. user.role) flatten it in the endpoint rather than fighting the slot.

Bootstrap projects: the default theme is Tailwind. Add @asyncSelectBootstrapV4Styles in <head> (3.2.0+) and be aware the 3.1.0 release was specifically a "undefined variable: slots" fix in the Bootstrap view — another reason to stay on the latest patch.

Debugging workflow: open the network tab first. You should see one debounced request to your endpoint per typing pause, carrying the search param. No request = @stack('scripts') or a JS/Alpine conflict. 401 = middleware or guard mismatch — try async-auth:web,persist. 200 with an empty dropdown = response shape; it must be {"data": [...]}, not a bare array.

Livewire re-render wipes the open dropdown. Expected — the component remounts Alpine state. If you need it open across a render, drive it from a parent event rather than expecting persistence.

Extension points are thin — plan accordingly. There is no event bus, no driver interface, no service container binding. Customization is: props, slots, theme CSS, and your endpoint. If you need virtualized scrolling for 10k options, or multi-column result tables, this is the wrong tool — reach for a full table-picker component instead.

Watch for the next Livewire major. The package absorbed the Livewire 4 slot change well, but with a single maintainer and no dependents, a Livewire 5 slot or Alpine API change could leave you waiting. Keeping the wrapper component (above) means the day that happens, the blast radius is one file.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk