Real-time search is one of those features that users expect everywhere but developers dread implementing. Between debouncing keystrokes, managing filter state, syncing the URL, and paginating results, you usually end up wrestling with a JavaScript framework, an API endpoint, and a pile of event listeners.
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 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 is already a working search component. The #[Url] attribute syncs the search property to the query string automatically, and WithPagination handles paginated results. But we are 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 is a subtle but important gotcha with LIKE queries. Characters like %, _, and \ are wildcards in SQL LIKE patterns. If a user searches for "100%", you will get unexpected results unless you escape those characters. Here is 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 one of those details that is easy to skip in a tutorial but will 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 is a common bug: 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 of results 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 does not double-escape the output. This keeps the highlighting safe from XSS while still rendering the HTML 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 are 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 is easy to forget in Livewire because you do not see the repeated queries in your browser network tab — they 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 is the full component class with all the pieces 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
What we built here — debounced search, filter dropdowns, URL synchronization, pagination reset, result highlighting, and loading states — would typically require a dedicated API endpoint, a JavaScript framework, a state management solution, and a router integration. With Livewire, it is a single PHP class and a Blade template.
The key takeaways are worth repeating. 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 do not neglect the performance basics: database indexes, eager loading, and computed properties.
Livewire is not a toy for simple interactions. It is a serious tool for building production search interfaces that feel as fast as a SPA, with a fraction of the complexity. Start simple, layer in features one at a time, and you will have a search experience your users actually enjoy using.