Skip to content
Bladewell
Guides

Laravel searchable select that loads from the database

A Select2 alternative in Blade: a select that searches your database as people type, with a scoped, throttled route and the chosen id checked.

php artisan bladewell:add select search number button

Uses Select, Search, Number and Button. New to Bladewell? Install it first.

Search instead of a long list

A select with a few hundred customers in it is slow to load and slow to scroll. Bladewell's select can search your app instead: give it search-url, and as people type it asks that route and shows what it answers. It does what Select2 or Tom Select would, with no jQuery and nothing extra to load, and it's keyboard and screen-reader friendly.

The search route

It answers GET ?q=… with JSON: each option's value, label and an optional meta shown beside it. The route answers whoever calls it, not only your form, so it only searches what the signed-in person may see, and sends back only what the field shows.

app/Http/Controllers/CustomerSearchController.php
namespace App\Http\Controllers;

use App\Models\Customer;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;

class CustomerSearchController extends Controller
{
    // GET ?q=… as people type, answered with JSON: value, label and meta for each option.
    public function __invoke(Request $request): Collection
    {
        $q = $request->validate(['q' => ['required', 'string', 'max:100']])['q'];

        // Their own customers (or their team's), never Customer::query(), which would list everyone's to whoever asks.
        return $request->user()->customers()
            // % and _ in what was typed match themselves. LOWER() on both sides, by the database, ignores case (SQLite's only
            // knows English letters, but what's typed exactly is always found).
            ->whereRaw("LOWER(name) LIKE LOWER(?) ESCAPE '!'", ['%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $q).'%'])
            ->orderBy('name')
            ->limit(20)
            ->get(['id', 'name', 'email'])
            // Only what the field shows: not the whole record.
            ->map(fn (Customer $customer): array => ['value' => $customer->id, 'label' => $customer->name, 'meta' => $customer->email]);
    }
}

It runs as people type: the field waits for a pause, cancels answers it no longer needs and remembers the ones it has, but throttle the route all the same.

routes/web.php
// routes/web.php: in web.php, not api.php, so the search shares the page's sign-in.
use App\Http\Controllers\CustomerSearchController;
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\ProductSuggestController;

Route::middleware('auth')->group(function () {
    // A request per pause in typing: 120 a minute per person is plenty.
    Route::get('/customers/search', CustomerSearchController::class)->middleware('throttle:120,1')->name('customers.search');

    Route::get('/invoices/create', [InvoiceController::class, 'create'])->name('invoices.create');
    Route::post('/invoices', [InvoiceController::class, 'store'])->name('invoices.store');
});

// Public, like the shop: throttled per visitor.
Route::get('/products/suggest', ProductSuggestController::class)->middleware('throttle:120,1')->name('products.suggest');

The form

The page holds no customers until someone searches, except the one already chosen: pass it in options, so the field can show its name: after a failed submit, the one that was picked, and in edit(), the saved one (old input still wins). It's only looked up when there's a whole number to look up.

app/Http/Controllers/InvoiceController.php
namespace App\Http\Controllers;

use App\Http\Requests\StoreInvoiceRequest;
use App\Models\Customer;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\View;

class InvoiceController extends Controller
{
    public function create(Request $request): View
    {
        return view('invoices.create', ['chosen' => $this->chosen($request, old('customer_id')), 'invoice' => null]);
    }

    public function edit(Request $request, Invoice $invoice): View
    {
        Gate::authorize('update', $invoice);

        return view('invoices.edit', ['chosen' => $this->chosen($request, old('customer_id', $invoice->customer_id)), 'invoice' => $invoice]);
    }

    /**
     * The page holds no customers but the chosen one, so the field can show its name: after a failed submit, or the
     * saved one when editing. Only looked up when there's a whole number to look up.
     *
     * @return list<array{value: int, label: string, meta: string}>
     */
    private function chosen(Request $request, mixed $id): array
    {
        if (filter_var($id, FILTER_VALIDATE_INT) === false) {
            return [];
        }

        return $request->user()->customers()->whereKey((int) $id)->get(['id', 'name', 'email'])
            ->map(fn (Customer $customer): array => ['value' => $customer->id, 'label' => $customer->name, 'meta' => $customer->email])
            ->all();
    }

    public function store(StoreInvoiceRequest $request): RedirectResponse
    {
        $invoice = $request->user()->invoices()->create($request->validated());

        return redirect()->route('invoices.show', $invoice)->with('success', 'Invoice created.');
    }
}

The field itself is one tag, the same in the edit view, where $invoice is the one being edited:

resources/views/invoices/create.blade.php
<form method="POST" action="{{ route('invoices.store') }}" class="grid max-w-md gap-5">
    @csrf
    {{-- Searches as people type (from the second letter). The one option the page holds is the customer already
         chosen: the saved one when editing ($invoice), or after a failed submit the one picked (old input wins). --}}
    <x-widget.select name="customer_id" label="Customer" placeholder="Search your customers"
        search-url="{{ route('customers.search') }}"
        :value="$invoice?->customer_id" :options="$chosen" required />
    <x-widget.number name="amount" label="Amount" :decimals="2" :min="0.01" required />
    <x-widget.button type="submit">Create invoice</x-widget.button>
</form>

The search only offers the person's own customers, but anyone can send the form any id. Check it through the same relationship:

app/Http/Requests/StoreInvoiceRequest.php
namespace App\Http\Requests;

use Closure;
use Illuminate\Foundation\Http\FormRequest;

class StoreInvoiceRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            // Through the same relationship the search used. Not exists:customers,id: that takes any customer's id
            // sent straight to the form, though the search never offered it.
            // bail: stop at the first failed rule, so the query below only ever sees a whole number.
            'customer_id' => ['bail', 'required', 'integer', function (string $attribute, mixed $value, Closure $fail): void {
                if (! $this->user()->customers()->whereKey($value)->exists()) {
                    $fail('Choose one of your customers.');
                }
            }],
            'amount' => ['required', 'numeric', 'min:0.01'],
        ];
    }
}

Autocomplete in a search box

For a search box that suggests as people type, like a shop's, use the search with suggest-url. A suggestion can carry an href, which opens that page when chosen.

resources/views/layouts/header.blade.php
{{-- In your header: suggestions as people type, each opening its product's page. Without the script, Enter still
     submits the search to products.index as an ordinary form. --}}
<form method="GET" action="{{ route('products.index') }}" role="search">
    <x-widget.search name="q" label="Search products" suggest-url="{{ route('products.suggest') }}" />
</form>

This route is public, so it only ever suggests what the shop shows everyone:

app/Http/Controllers/ProductSuggestController.php
namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;

class ProductSuggestController extends Controller
{
    public function __invoke(Request $request): Collection
    {
        $q = $request->validate(['q' => ['required', 'string', 'max:100']])['q'];

        // It answers anyone, signed in or not: only what the shop shows everyone, never drafts or hidden products.
        return Product::query()
            ->where('is_published', true)
            ->whereRaw("LOWER(name) LIKE LOWER(?) ESCAPE '!'", ['%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $q).'%'])
            // Each suggestion shows its category: loaded with them, not one query per result.
            ->with('category:id,name')
            ->orderBy('name')
            ->limit(8)
            // Only what a suggestion shows: a public route that runs as people type.
            ->get(['id', 'name', 'category_id'])
            ->map(fn (Product $product): array => [
                'label' => $product->name,
                'meta' => $product->category?->name,
                'href' => route('products.show', $product),
            ]);
    }
}

On a large table

A search for %term% reads every row, which is fine for thousands. For more, match the start of the name, term%. An index on name alone won't help, because the search wraps it in LOWER(): index LOWER(name) itself (an expression index; on PostgreSQL with text_pattern_ops), or use full-text search, and check the query each one runs. Keep the limit(), and if a few searches repeat a lot, cache their answers briefly, keyed by the person as well as the query.