This guide shows how to implement advanced real-time search in Laravel using Scout and Typesense.
📋 Table of Contents
- Introduction
- Laravel Scout Setup
- Adding Typesense to Docker
- Install the Typesense Scout Driver
- Scout & Typesense Configuration
- Make Your Model Searchable
- Index Your Data
- Backend Suggestion Endpoint
- Frontend: Instant Search in React
- Conclusion
🧠 Introduction
Instant search is a must-have for modern web apps. This guide shows how to build a production-ready solution in Laravel with:
- Laravel Scout
- Typesense
- React (Inertia.js)
🛠 Laravel Scout Setup
Install Scout:
composer require laravel/scout
Publish config:
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
🐳 Adding Typesense to Docker
Update docker-compose.yml:
typesense:
image: typesense/typesense:0.25.1
container_name: blog_typesense
restart: unless-stopped
ports:
- "8108:8108"
environment:
- TYPESENSE_API_KEY=your_typesense_api_key
- TYPESENSE_DATA_DIR=/data
- TYPESENSE_ENABLE_CORS=true
volumes:
- typesense_data:/data
networks:
- laravel-network
volumes:
typesense_data:
driver: local
Start Typesense:
docker-compose up -d typesense
📦 Install the Typesense Scout Driver
composer require typesense/laravel-scout-typesense-engine
⚙️ Scout & Typesense Configuration
In .env:
SCOUT_DRIVER=typesense
SCOUT_ENABLED=true
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=your_typesense_api_key
In config/scout.php:
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY'),
'nodes' => [[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
]],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => 2,
'healthcheck_interval_seconds' => 30,
'num_retries' => 3,
'retry_interval_seconds' => 1,
],
'model-settings' => [
App\Models\Post::class => [
'collection-schema' => [
'fields' => [
['name' => 'title', 'type' => 'string'],
['name' => 'content', 'type' => 'string'],
['name' => 'tags_names', 'type' => 'string[]'],
['name' => 'published_at', 'type' => 'int32'],
],
'default_sorting_field' => 'published_at',
],
'search-parameters' => [
'query_by' => 'title,content,tags_names',
'query_by_weights' => '5,3,2,1',
'sort_by' => 'published_at:desc',
'filter_by' => 'published_at:<' . now()->timestamp,
'drop_tokens_threshold' => 1,
'typo_tokens_threshold' => 100,
'prefix' => true,
],
],
],
]
🧩 Make Your Model Searchable
In Post.php:
use Laravel\Scout\Searchable;
class Post extends Model
{
use Searchable;
public function toSearchableArray(): array
{
$this->load(['tags']);
return [
'id' => (string) $this->id,
'title' => $this->title,
'content' => $this->content,
'tags_names' => $this->tags->pluck('name')->toArray(),
'published_at' => $this->published_at->timestamp,
];
}
public function searchableAs(): string
{
return 'posts_index';
}
}
📥 Index Your Data
php artisan scout:import App\Models\Post
🔙 Backend Suggestion Endpoint
// routes/api.php
Route::get('/suggest-posts', [BlogController::class, 'suggestPosts']);
// BlogController.php
public function suggestPosts(Request $request)
{
$query = $request->input('query');
if (trim($query) === '') {
return response()->json([]);
}
$results = Post::getSuggestions($query);
return response()->json($results);
}
// Post.php
public static function getSuggestions(string $query)
{
return self::search($query)
->query(fn (Builder $query) => $query->where('published_at', '<=', now()))
->take(5)
->get()
->map(fn ($post) => [
'id' => $post->id,
'title' => $post->title,
'slug' => $post->slug,
])
->values();
}
💻 Frontend: Instant Search in React
React search bar example:
const [search, setSearch] = useState(filters?.search);
const [sort, setSort] = useState(filters?.sort);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const handleSearch = useCallback((value: string) => {
setSearch(value);
setHighlightedIndex(-1);
}, []);
const handleSort = useCallback((value: string) => {
setSort(value);
}, []);
const handleSuggestionSelect = useCallback(
(suggestion: Suggestion) => {
setSuggestionsOpen(false);
setHighlightedIndex(-1);
router.visit(route('blog.show', { post: suggestion.slug }));
},
[],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
e.preventDefault();
const selected = suggestions[highlightedIndex];
if (selected) {
handleSuggestionSelect(selected);
}
} else if (e.key === 'Escape') {
setSuggestionsOpen(false);
}
},
[suggestions, highlightedIndex, handleSuggestionSelect],
);
useEffect(() => {
const timeout = setTimeout(() => {
if (search && search.length >= 2) {
axios
.get(route('blog.suggest-posts', { query: search }))
.then((res) => res.data)
.then(setSuggestions);
} else {
setSuggestions([]);
}
const params = {
search,
sort,
};
router.reload({
only: ['posts'],
data: params,
});
}, 400);
return () => clearTimeout(timeout);
}, [sort, search]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!inputRef.current?.contains(e.target as Node)) {
setSuggestionsOpen(false);
}
};
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
Input with suggestions dropdown:
<div className="relative w-full" ref={inputRef}>
<Input
className="w-full"
placeholder={__('Search posts...')}
value={search}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => setSuggestionsOpen(true)}
onKeyDown={handleKeyDown}
aria-label={__('Search posts')}
aria-expanded={suggestionsOpen}
aria-controls="search-suggestions"
role="combobox"
/>
{suggestionsOpen && suggestions.length > 0 && (
<ul
id="search-suggestions"
className="absolute z-10 mt-1 w-full rounded-md border bg-white shadow-md dark:bg-zinc-900"
role="listbox"
>
{suggestions.map((s, i) => (
<li
key={s.id}
role="option"
aria-selected={i === highlightedIndex}
className={`cursor-pointer px-4 py-2 ${
i === highlightedIndex ? 'bg-muted font-medium' : 'hover:bg-muted'
}`}
onMouseDown={() => handleSuggestionSelect(s)}
>
{s.title}
</li>
))}
</ul>
)}
</div>
✅ Conclusion
You now have a production-grade instant search feature with:
- Backend indexing via Laravel Scout & Typesense
- Blazing-fast frontend suggestions via React + Inertia.js
Follow me on LinkedIn for more Laravel and DevOps content!
Would you like to learn more about instant search? Leave a comment below!