Real-time search is one of those features users expect everywhere but developers dread building. Between debouncing keystrokes, managing filter state, syncing the URL, and paginating results, you usually end up wrestling with a JavaScript framework, a dedicated API endpoint, and a pile of event listeners just to get something that feels decent.
Laravel Livewire eliminates all of that. You get a fully reactive search experience with filters, pagination, URL sync, and result highlighting — all from a single PHP class and a Blade view. No custom JavaScript required.
Let's build one from scratch.
Creating the Livewire Component
Start by generating a new Livewire component:
php artisan make:livewire SearchArticlesThis creates two files: the component class at app/Livewire/SearchArticles.php and its view at resources/views/livewire/search-articles.blade.php. The component class is where all the search logic lives. Start with a search property and a basic query:
<?php
namespace App\Livewire;
use App\Models\Article;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class SearchArticles extends Component
{
use WithPagination;
#[Url]
public string $search = '';
public function render()
{
$articles = Article::query()
->when($this->search, function ($query) {
$query->where('title', 'like', '%' . $this->search . '%')
->orWhere('body', 'like', '%' . $this->search . '%');
})
->latest()
->paginate(15);
return view('livewire.search-articles', [
'articles' => $articles,
]);
}
}
That's already a working Laravel Livewire search component. The #[Url] attribute syncs the search property to the query string automatically, and WithPagination handles paginated results. But we're going to go further.
The Search Input with Debounce
In the Blade view, bind the input to the search property with a 300ms debounce. This prevents a network request on every single keystroke while still feeling responsive:
<div>
<input
type="text"
wire:model.live.debounce.300ms="search"
placeholder="Search articles..."
class="w-full rounded-lg border-gray-300 px-4 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<div class="mt-6 space-y-4">
@forelse ($articles as $article)
<div class="rounded-lg border p-4">
<h3 class="text-lg font-semibold">{{ $article->title }}</h3>
<p class="mt-1 text-gray-600">{{ Str::limit($article->body, 150) }}</p>
</div>
@empty
<p class="text-gray-500">No articles found.</p>
@endforelse
</div>
<div class="mt-6">
{{ $articles->links() }}
</div>
</div>
The wire:model.live.debounce.300ms directive is doing three things at once. The live modifier sends updates to the server as the user types (rather than waiting for blur or submit). The debounce.300ms modifier waits 300 milliseconds after the last keystroke before firing, which prevents hammering the server during fast typing. Together, they create a search experience that feels instant without being wasteful.
Escaping User Input in LIKE Queries
There's a subtle but important gotcha with LIKE queries that most Livewire search tutorials skip. Characters like %, _, and \ are wildcards in SQL LIKE patterns. If a user searches for "100%", you'll get unexpected results unless you escape those characters. Here's a helper method to add to your component:
private function escapeLike(string $value): string
{
return str_replace(
['\\', '%', '_'],
['\\\\', '\\%', '\\_'],
$value
);
}
Then use it in your query:
$escaped = $this->escapeLike($this->search);
$articles = Article::query()
->when($this->search, function ($query) use ($escaped) {
$query->where('title', 'like', "%{$escaped}%")
->orWhere('body', 'like', "%{$escaped}%");
})
->latest()
->paginate(15);
This is the kind of detail that's easy to skip in a tutorial but will absolutely bite you in production.
Adding Filter Dropdowns
Most search interfaces need more than a text input. Add category and status filters as additional properties with the #[Url] attribute so they sync to the URL too:
#[Url]
public string $search = '';
#[Url]
public string $category = '';
#[Url]
public string $status = '';
public function render()
{
$escaped = $this->escapeLike($this->search);
$articles = Article::query()
->when($this->search, function ($query) use ($escaped) {
$query->where('title', 'like', "%{$escaped}%")
->orWhere('body', 'like', "%{$escaped}%");
})
->when($this->category, function ($query) {
$query->where('category_id', $this->category);
})
->when($this->status, function ($query) {
$query->where('status', $this->status);
})
->latest()
->paginate(15);
return view('livewire.search-articles', [
'articles' => $articles,
'categories' => Category::pluck('name', 'id'),
]);
}
In the Blade view, add select dropdowns bound to these properties:
<div class="flex gap-4">
<select wire:model.live="category" class="rounded-lg border-gray-300">
<option value="">All Categories</option>
@foreach ($categories as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
<select wire:model.live="status" class="rounded-lg border-gray-300">
<option value="">All Statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
</div>
Because every filter has the #[Url] attribute, the full state of the search is always reflected in the browser URL. Users can bookmark filtered views, share links with colleagues, or hit the back button and return to their exact previous search — all for free.
Resetting Pagination on Search Changes
Here's a bug I see in almost every Livewire search component: a user is on page 5 of results, types a new search term, and sees "No results" because the query now only has 2 pages but Livewire is still requesting page 5. Fix this by resetting pagination whenever any filter changes:
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedCategory(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
Livewire's updated{PropertyName} lifecycle hooks fire whenever a property changes. Calling $this->resetPage() sets the paginator back to page 1. If you have many filters, you can consolidate with a single updated() method that catches all property updates:
public function updated($property): void
{
if (in_array($property, ['search', 'category', 'status'])) {
$this->resetPage();
}
}
Highlighting Matched Text in Results
Highlighting the matched search term in results gives users immediate visual feedback about why each result appeared. Create a simple Blade component or helper for this:
// app/Helpers/SearchHelper.php
namespace App\Helpers;
use Illuminate\Support\HtmlString;
class SearchHelper
{
public static function highlight(string $text, string $term): HtmlString
{
if (empty($term)) {
return new HtmlString(e($text));
}
$escaped = e($text);
$escapedTerm = preg_quote(e($term), '/');
$highlighted = preg_replace(
"/({$escapedTerm})/i",
'<mark class="bg-yellow-200 rounded px-0.5">$1</mark>',
$escaped
);
return new HtmlString($highlighted);
}
}
Use it in your Blade view:
@use('App\Helpers\SearchHelper')
<h3 class="text-lg font-semibold">
{!! SearchHelper::highlight($article->title, $search) !!}
</h3>
<p class="mt-1 text-gray-600">
{!! SearchHelper::highlight(Str::limit($article->body, 150), $search) !!}
</p>
Notice that we escape the text before inserting the <mark> tags, and we return an HtmlString so Blade doesn't double-escape the output. This keeps the highlighting XSS-safe while still rendering the mark tags correctly.
Performance Tips
A search component that feels sluggish is worse than no search at all. Here are the optimizations that matter most.
Add database indexes. If you're running LIKE queries against title and body columns, make sure they have appropriate indexes. For MySQL, consider a FULLTEXT index and switch to whereFullText() instead of LIKE for significantly better performance on large tables:
// Migration
$table->fullText(['title', 'body']);
// Query
Article::whereFullText(['title', 'body'], $this->search)
Use computed properties. Livewire's #[Computed] attribute caches a value for the duration of a single request. If your view references the query results multiple times, wrap the query in a computed property to avoid running it twice:
use Livewire\Attributes\Computed;
#[Computed]
public function articles()
{
$escaped = $this->escapeLike($this->search);
return Article::query()
->when($this->search, function ($query) use ($escaped) {
$query->where('title', 'like', "%{$escaped}%")
->orWhere('body', 'like', "%{$escaped}%");
})
->when($this->category, fn ($query) => $query->where('category_id', $this->category))
->when($this->status, fn ($query) => $query->where('status', $this->status))
->latest()
->paginate(15);
}
Then reference it in your view with $this->articles or just $articles in Blade.
Eager load relationships. If your article cards display the author name, category badge, or tag list, add ->with(['author', 'category', 'tags']) to the query to avoid N+1 queries. This one's easy to forget in Livewire because you don't see the repeated queries in your browser network tab — they all happen server-side.
Add loading states. Even with optimized queries, network latency can make the UI feel unresponsive. Livewire's wire:loading directive lets you show a spinner or fade the results while a request is in flight:
<div wire:loading.class="opacity-50 pointer-events-none" class="transition-opacity">
{{-- Search results here --}}
</div>
<div wire:loading class="mt-4 text-center text-gray-500">
Searching...
</div>
The Complete Component
Here's the full Livewire search component class with everything wired together:
<?php
namespace App\Livewire;
use App\Models\Article;
use App\Models\Category;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class SearchArticles extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $category = '';
#[Url]
public string $status = '';
public function updated($property): void
{
if (in_array($property, ['search', 'category', 'status'])) {
$this->resetPage();
}
}
#[Computed]
public function articles()
{
$escaped = $this->escapeLike($this->search);
return Article::query()
->with(['author', 'category'])
->when($this->search, function ($query) use ($escaped) {
$query->where('title', 'like', "%{$escaped}%")
->orWhere('body', 'like', "%{$escaped}%");
})
->when($this->category, fn ($query) => $query->where('category_id', $this->category))
->when($this->status, fn ($query) => $query->where('status', $this->status))
->latest()
->paginate(15);
}
public function render()
{
return view('livewire.search-articles', [
'categories' => Category::pluck('name', 'id'),
]);
}
private function escapeLike(string $value): string
{
return str_replace(
['\\', '%', '_'],
['\\\\', '\\%', '\\_'],
$value
);
}
}
Conclusion
If you'd built this same search experience with a JavaScript framework, you'd be looking at a dedicated API endpoint, a state management layer, a router integration, and probably a few hundred lines of fetch/debounce/filter logic. With Livewire, it's one PHP class and a Blade template.
The things that'll save you from debugging headaches later: use wire:model.live.debounce to balance responsiveness with server load. Always escape user input in LIKE queries. Attach #[Url] to every filter property so the URL always reflects the current state. Reset pagination when filters change. And don't skip the performance basics — database indexes, eager loading, and computed properties make the difference between a search that feels snappy and one that feels broken.
This is the kind of feature where Livewire really shines. You get a search interface that feels as fast as a SPA without any of the SPA complexity. Start with the basic version, layer in features as you need them, and you'll end up with something your users actually enjoy using.