Most Laravel applications start with search as a where('title', 'like', "%{$q}%"). It works on the demo data and then quietly stops working: no typo tolerance, no relevance ranking, no way to search across relations, and a full table scan on every keystroke. Somewhere between ten thousand and a hundred thousand rows it becomes the slowest query in the application.
This tutorial replaces that with Laravel Scout backed by Typesense: install, index design, filters and facets, the scoping rules you must get right in a multi-tenant or permissioned app, queued imports, zero-downtime reindexing, and how to test search without running a search engine in CI.
You will need a Laravel 12 or 13 application on PHP 8.3+, a queue worker, and Docker for local Typesense.
Why not just use MySQL or Postgres full text?
Be honest about the requirement before adding infrastructure.
- Database full-text indexes (
MATCH ... AGAINST, or Postgrestsvectorwith a GIN index) are a genuinely good answer when you need exact-ish keyword matching over one table and you do not want another service to operate. Scout's database driver wraps this. - A search engine (Typesense, Meilisearch, Algolia, OpenSearch) earns its keep when you need typo tolerance, sub-50ms search-as-you-type, relevance tuning, synonyms, facet counts, or search across denormalised fields from several tables.
Typesense is the usual pick for our clients who want engine-quality search without a per-search bill: it is open source, a single Go binary, and Scout has first-party support for it. Everything below except the driver config applies equally to Meilisearch.
Install
composer require laravel/scout typesense/typesense-php
php artisan vendor:publish --tag=laravel-scout-config
Run Typesense locally with Docker Compose:
services:
typesense:
image: typesense/typesense:29.0
ports:
- '8108:8108'
volumes:
- typesense-data:/data
command: '--data-dir /data --api-key=local-dev-key --enable-cors'
volumes:
typesense-data:
And point the app at it:
SCOUT_DRIVER=typesense
SCOUT_QUEUE=true
TYPESENSE_API_KEY=local-dev-key
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
SCOUT_QUEUE=true is not optional in production. Without it, every model save makes a synchronous HTTP call to the search engine inside your request cycle, so a search outage becomes a write outage.
Make a model searchable
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Article extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => (string) $this->id,
'title' => $this->title,
'summary' => $this->summary,
'body' => strip_tags($this->body),
'author_name' => $this->author->name,
'tags' => $this->tags->pluck('name')->all(),
'team_id' => (string) $this->team_id,
'status' => $this->status,
'published_at' => $this->published_at?->timestamp,
];
}
public function shouldBeSearchable(): bool
{
return $this->status === 'published';
}
protected function makeAllSearchableUsing($query)
{
return $query->with(['author', 'tags']);
}
}
Four things are doing real work here.
toSearchableArrayis a denormalised document, not your row. Flatten the relations you want to search or filter on — author name, tag names — because the engine cannot join.shouldBeSearchablekeeps drafts out of the index automatically. When a draft is published, Scout indexes it; when it is unpublished, Scout removes it.makeAllSearchableUsingeager-loads relations during a bulk import. Without it,scout:importon 200k articles issues millions of queries.team_idandstatusare in the document so they can be used as filters. This matters in the next section.
Do not put secrets, password hashes or internal notes in the document. Anything in the index can end up in a search response.
Declare the Typesense schema
Typesense is typed, so define the collection schema in config/scout.php rather than letting it be guessed:
'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'),
]],
'connection_timeout_seconds' => 2,
],
'model-settings' => [
App\Models\Article::class => [
'collection-schema' => [
'fields' => [
['name' => 'id', 'type' => 'string'],
['name' => 'title', 'type' => 'string'],
['name' => 'summary', 'type' => 'string', 'optional' => true],
['name' => 'body', 'type' => 'string', 'optional' => true],
['name' => 'author_name', 'type' => 'string', 'facet' => true],
['name' => 'tags', 'type' => 'string[]', 'facet' => true],
['name' => 'team_id', 'type' => 'string', 'facet' => true],
['name' => 'status', 'type' => 'string', 'facet' => true],
['name' => 'published_at', 'type' => 'int64'],
],
'default_sorting_field' => 'published_at',
],
'search-parameters' => [
'query_by' => 'title,summary,tags,author_name,body',
'query_by_weights' => '5,3,3,2,1',
'num_typos' => 1,
'prefix' => true,
],
],
],
],
query_by_weights is your relevance dial: a match in the title should beat a match buried in the body. prefix => true is what makes search-as-you-type feel instant, because "lar" matches "Laravel". num_typos => 1 is a sensible default; 2 on short fields produces surprising matches.
Create the collection and import:
php artisan scout:import "App\Models\Article"
Searching, filtering and paginating
use App\Models\Article;
$results = Article::search($request->string('q'))
->where('team_id', (string) $request->user()->current_team_id)
->whereIn('status', ['published'])
->orderBy('published_at', 'desc')
->paginate(20);
Scout's where is an exact-match filter passed to the engine, not an Eloquent clause — no LIKE, no operators beyond what the driver supports. For anything more complex, drop to raw Typesense parameters:
$results = Article::search($query, function ($typesense, string $q, array $options) use ($teamId) {
$options['filter_by'] = "team_id:={$teamId} && status:=published";
$options['facet_by'] = 'tags,author_name';
$options['max_facet_values'] = 20;
$options['per_page'] = 20;
return $typesense->getCollections()['articles']->getDocuments()->search($options);
})->raw();
->raw() returns the engine response including facet_counts, which is what you render as "Tags (12)" sidebar filters. When you call paginate() or get() instead, Scout takes the returned IDs and hydrates real Eloquent models with a single whereIn query, so your Blade views, policies and API resources keep working unchanged.
One subtlety worth knowing: because hydration is a second query, a model deleted after indexing but before hydration simply disappears from the result set, and your total count can be one higher than the rows you render. Render counts from the hydrated collection where exactness matters.
Scoping: the part that goes wrong
Search is the most common way a permissioned Laravel app leaks data. Eloquent global scopes and policies do not apply to a Scout query — the filtering happens in the engine, and the engine only knows what you put in the document.
Three rules we apply on every engagement:
- Every tenant- or permission-relevant attribute goes into the document (
team_id,visibility,owner_id) and every search applies the filter. Not in a controller by hand — in one place. - Fail closed. Wrap search in a dedicated class or macro so nobody can call
Article::search($q)unscoped from a new controller:
namespace App\Search;
use App\Models\Article;
use App\Models\User;
use Laravel\Scout\Builder;
class ArticleSearch
{
public static function for(User $user, string $query): Builder
{
return Article::search($query)
->where('team_id', (string) $user->current_team_id)
->where('status', 'published');
}
}
- Test the negative case. An isolation test that asserts a user cannot find another tenant's article by an exact title match is worth more than any number of happy-path tests.
If you are on Typesense specifically, scoped API keys let you embed the filter in a search key issued per user, which is essential if the browser queries Typesense directly for instant search. Never ship the admin API key to the front end.
Queued indexing at scale
With SCOUT_QUEUE=true, model changes dispatch Scout\Jobs\MakeSearchable. Give it its own queue and worker so a bulk update cannot starve mail or webhooks:
'queue' => [
'connection' => 'redis',
'queue' => 'scout',
],
For a mass update, never loop and save. Model::withoutSyncingToSearch() lets you write first and index once:
Article::withoutSyncingToSearch(function () {
Article::where('team_id', $team->id)->update(['status' => 'archived']);
});
Article::where('team_id', $team->id)->searchable();
Also remember that update() and delete() on a query builder bypass model events entirely, so the index silently drifts. Anywhere you use mass updates, follow them with an explicit ->searchable() or ->unsearchable() call — or schedule a nightly reconciliation.
Zero-downtime reindexing
Changing analyzers, weights or schema fields means rebuilding the collection. scout:flush then scout:import leaves users with an empty search box for the duration. Use an alias instead:
// config/scout.php
'prefix' => env('SCOUT_PREFIX', ''),
The pattern: import into articles_2026_03_11, verify document count and spot-check a few queries, then point the articles alias at the new collection and drop the old one. Wrap it in an Artisan command so the deploy step is one line and reversible — the previous collection is still there until you delete it.
Always verify counts before switching:
$expected = Article::where('status', 'published')->count();
$indexed = $client->collections[$new]->retrieve()['num_documents'];
throw_if($indexed < $expected * 0.99, new RuntimeException('Index incomplete'));
Testing without a search engine
Set the collection driver in phpunit.xml and Scout searches run in memory against the database:
<env name="SCOUT_DRIVER" value="collection"/>
use App\Models\Article;
use App\Models\User;
use App\Search\ArticleSearch;
it('finds published articles in the users team', function () {
$user = User::factory()->create();
$mine = Article::factory()->for($user->currentTeam)->published()->create(['title' => 'Scaling Laravel queues']);
$draft = Article::factory()->for($user->currentTeam)->create(['status' => 'draft', 'title' => 'Scaling Laravel queues']);
$results = ArticleSearch::for($user, 'queues')->get();
expect($results->pluck('id'))->toContain($mine->id)->not->toContain($draft->id);
});
it('never returns another teams articles', function () {
$user = User::factory()->create();
$other = Article::factory()->published()->create(['title' => 'Confidential merger plan']);
expect(ArticleSearch::for($user, 'Confidential merger plan')->get())->toBeEmpty();
});
The collection driver does simple substring matching, so it validates your filters, scoping and hydration — the parts that break your application — but not relevance or typo tolerance. Keep a small suite of relevance tests that runs against a real Typesense container in CI: a dozen fixture documents and assertions that the expected result ranks first. Those are the tests that catch a weight change nobody meant to make.
A short production checklist
SCOUT_QUEUE=true, dedicatedscoutqueue, alerting on failed jobs and queue depth.- Search request timeout of 1–2 seconds with a graceful fallback (a database query or an honest error), so a search outage degrades one feature instead of the site.
- Rate limit public search endpoints; search-as-you-type multiplies traffic by every keystroke.
- Log queries with zero results — it is the cheapest product research you will ever get, and it tells you which synonyms to add.
- Back up nothing in the engine. Treat the index as derived data you can rebuild from the database at any time, and prove it by rebuilding regularly.
Search is one of those features that looks like an afternoon and turns into a quarter when scoping, reindexing and relevance arrive late. If you would like help designing or fixing search in a Laravel application, see our Laravel performance optimization and feature enhancement services, or get in touch.