laravel/scout
Laravel Scout adds driver-based full-text search to Eloquent models, automatically syncing model changes to indexes. Supports Algolia, Meilisearch, and Typesense.
Scout is a thin, driver-based abstraction over an external search engine. You install it, tell it which engine to use, and then treat Model::search() as an Eloquent-like builder. The package itself does not store an index — it syncs your models to Algolia, Meilisearch, Typesense, the database, or an in-memory collection.
Minimal path to first search:
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
Set the driver in .env:
SCOUT_DRIVER=collection # or database, meilisearch, typesense, algolia
Then opt a model in:
use Laravel\Scout\Searchable;
class Post extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'author' => $this->author->name,
'published_at' => $this->published_at?->timestamp,
];
}
}
Backfill existing rows and query:
php artisan scout:import "App\Models\Post"
$posts = Post::search('laravel scout')
->where('author', 'Taylor')
->orderBy('published_at', 'desc')
->paginate(20);
Where to look first: config/scout.php (driver, queue, chunk sizes, prefix), the Searchable trait, Laravel\Scout\Builder (the query surface), and src/Engines/* when an engine behaves differently than you expect. The README is intentionally thin; the real reference is the Laravel docs plus the changelog, because engine-specific fixes land frequently.
Start with collection in local/testing and one real engine in staging. Use the database engine only if you need zero extra infrastructure and can live without typo tolerance, facets, and relevance tuning — it relies on MySQL/PostgreSQL full-text indexes and gives you the least search-like behaviour.
Treat toSearchableArray() as a denormalized view, not a serialization of the model. Index flat scalars: IDs, titles, slugs, display names, timestamps as integers, booleans as booleans. Relationships are the main source of N+1 pain — either eager-load them or compute values in a dedicated transformer/accessor that does not lazy-load.
Control what gets indexed. shouldBeSearchable() is the gate for drafts, unpublished, disabled, or tenant-scoped records. searchableAs() controls the index/collection name — use it for per-tenant indices or environment prefixes rather than sharing one index across apps.
Querying looks like Eloquent but is not Eloquent. where(), whereIn(), whereNotIn(), orderBy(), take(), paginate() are translated into engine filters/sorts. Anything the engine cannot express is not available; there is no whereHas, no joins, no LIKE. Design your index so every filter and sort field is present in toSearchableArray().
Use the callback for engine-specific features (facets, highlights, geo, custom ranking):
Post::search('scout', function ($engine, string $query, array $options) {
$options['highlightPreTag'] = '<mark>';
return $engine->search($query, $options);
})->raw();
->raw() returns the engine response untouched — use it for facets, highlight snippets, and relevance scores. ->get() re-queries the database and hydrates models, preserving engine order.
Keep the sync pipeline deliberate. Enable SCOUT_QUEUE=true for production writes, and confirm after_commit behavior in config/scout.php so jobs are not dispatched before the transaction commits. For request-critical updates (e.g. the user just edited a record and immediately searches for it), use searchableSync() / unsearchableSync() (v10.14.1+) to index inline instead of queueing. For bulk seeding or imports, wrap in Model::withoutSyncingToSearch(fn () => ...) so you do not enqueue thousands of redundant jobs.
Backfill with intent. scout:import uses chunkById; add --chunk=1000 for large tables, and implement makeAllSearchableUsing($query) to eager-load the relations your toSearchableArray() touches. This is the single biggest lever on import time and memory.
Test against the collection driver. Set SCOUT_DRIVER=collection in phpunit.xml; it behaves like a real engine for search, where, orderBy, and paginate without external services. Alternatively, Scout::withoutSyncingToSearch() around factories prevents index churn.
Wrap custom engines behind Scout::extend() when you need an internal search service or a shim around an unsupported engine. Bind a custom MakeSearchable / RemoveFromSearch job if you need different queue names, retries, or backoff — the jobs are overridable via the container.
Aggregate multi-model search yourself. Scout searches one index at a time; there is no native cross-index query. For a global search box, query each model and merge results in PHP, or use ->raw() to call the engine directly against multiple indices.
toSearchableArray() is not a source of truth.filterableAttributes / sortableAttributes; Algolia needs attributesForFaceting; Typesense needs the fields in its schema. Until configured, where() and orderBy() fail or silently return nothing — this is the most common "Scout is broken" report. Sync index settings as part of deployment.paginate() plus custom callbacks has historically produced wrong totals. Several engines have had fixes here (Typesense pagination fixes in 10.11.3/10.11.4/10.14.2). If your total count looks wrong, call ->raw() and compare against the engine's reported count before blaming your query.Model::search('') is engine-dependent; guard the entry point with filled($request->q) and fall back to a normal Eloquent listing.after_commit is enabled; enable it. The flip side is that a queued RemoveFromSearch may run after the model is already gone — Scout handles it, but your custom engine might not.shouldBeSearchable() and verify restore behavior — restoring a model does not always re-trigger indexing; a re-save or scout:import may be needed.getScoutKey() and getScoutKeyName(); ensure the key is a stable string. UUID models should be explicit about key handling, and queued removal jobs must carry the same key.searchableAs() with an app/env prefix, or the config-level prefix, so staging never writes into production's index.Model::search(...)->raw() to see exactly what the engine returned, then ->keys() to see the IDs Scout will hydrate, then the query log to see what SQL re-query ran. If the engine returns results but your page is empty, it is almost always the DB re-query (soft deletes, global scopes, or a tenant constraint that excludes those IDs).Scout::extend(), Scout::engine(), makeAllSearchableUsing(), searchableAs(), shouldBeSearchable(), the Laravel\Scout\Jobs\MakeSearchable / RemoveFromSearch bindings, and afterRawSearch (v10.13) for post-processing engine responses.whereIn accepting Arrayable, orderByDesc, newScoutQuery() for the database engine). Pin a known-good minor and read the notes before upgrading.How can I help you explore Laravel packages today?