Wiring Elasticsearch into Laravel: Scout, Sync and Zero-Downtime Reindexing
Scout for the write path, raw DSL for the read path: denormalized documents, cascade updates, drift reconciliation, filter-vs-must, hydration without N+1, and the alias pattern from day one.
The gap between "Elasticsearch runs in Docker" and "search works in production" is wider than any tutorial admits, and almost all of it is integration: keeping the index faithful to the database, shaping documents for the screens you actually have, and surviving the day you need to reindex everything with zero downtime. Here's how I wire Elasticsearch into a Laravel application — with Scout where it helps, around it where it doesn't.
Decision zero: Scout or direct client?
Laravel Scout gives you model observers, queued sync and a tidy Product::search('kahve') facade. With a community driver (or Elastic's official one) it's a genuinely good on-ramp. Its ceiling: Scout's query surface is intentionally small — real filters, facets/aggregations, multi-field boosts and highlights mean dropping to raw DSL anyway. My standard shape therefore: Scout for the write path (its observer + queue machinery is exactly right), a thin query class over the official PHP client for the read path. Best of both, no framework fights.
The write path: indexing without lies
Define what a document is — deliberately, not by serializing the model:
// On the Product model
public function toSearchableArray(): array
{
return [
'title' => $this->title,
'description' => strip_tags($this->description),
'brand' => $this->brand?->name, // denormalized ON PURPOSE
'category_id' => $this->category_id,
'price_minor' => $this->price_minor,
'is_active' => $this->is_active,
'created_at' => $this->created_at?->toIso8601String(),
];
}
public function shouldBeSearchable(): bool
{
return $this->is_active; // drafts never pollute the index
}
Three rules baked in there. Denormalize relations into the document — Elasticsearch has no JOINs worth using; the brand name lives in the product document, which means (rule two) parent updates must cascade: a brand rename touches every product document, so the Brand observer re-queues its products ($brand->products()->searchable() — Scout batches it). And sync through the queue, always (SCOUT_QUEUE=true): an Elasticsearch hiccup should retry a job, not fail a checkout.
The drift problem remains — queues drop, deploys race, and six months later the index disagrees with the database in ways nobody notices until a customer does. Schedule a nightly reconciliation: compare updated-at watermarks or counts per shard of ID space, re-sync the diff, and alert if drift exceeds a threshold. Twenty lines, and it converts "search is subtly wrong" from a mystery into a metric.
The read path: a query class you can unit test
final class ProductSearch
{
public function __construct(private Client $es) {}
public function search(ProductSearchRequest $req): SearchResult
{
$response = $this->es->search([
'index' => 'products',
'body' => [
'query' => [
'bool' => [
'must' => [[
'multi_match' => [
'query' => $req->term,
'fields' => ['title^3', 'brand^2', 'description'],
'fuzziness' => 'AUTO', // typo tolerance
],
]],
'filter' => array_filter([ // filters: no scoring, cacheable
$req->categoryId ? ['term' => ['category_id' => $req->categoryId]] : null,
$req->maxPrice ? ['range' => ['price_minor' => ['lte' => $req->maxPrice]]] : null,
['term' => ['is_active' => true]],
]),
],
],
'aggs' => [
'by_category' => ['terms' => ['field' => 'category_id', 'size' => 20]],
],
'from' => $req->offset(), 'size' => $req->perPage,
],
]);
return SearchResult::fromElastic($response); // ids + facets + total
}
}
The structural choice that matters: filter vs must. Filters don't score and get cached; scoring clauses do and don't. Category, price, active-flag — filters. The typed text — must. Mixing them up costs both relevance quality and latency. (Why these knobs exist at all is the fundamentals post.)
Then hydrate: take the returned IDs, load models with one whereIn (plus eager loads — don't reimport N+1 through the back door), and reorder to match Elasticsearch's ranking: $products->sortBy(fn ($p) => array_search($p->id, $ids)). Search owns ranking; the database owns truth.
Zero-downtime reindexing: aliases from day one
Mappings are immutable, so reindexing is a when, not an if — and the alias pattern makes it boring. The application never talks to products_v7; it talks to the alias products:
1. Create products_v8 with the new mapping
2. Bulk-reindex into it (source of truth: the DATABASE, not the old index)
3. Dual-write window: observers write to both (or replay the queue backlog)
4. Atomic swap: POST /_aliases { remove products→v7, add products→v8 }
5. Keep v7 for a day, then delete
Set the alias up on day one — retrofitting it during your first emergency reindex is a special kind of character-building I don't recommend.
Series continues with shards, heap and ILM in production — and if you're not yet sure you need Elasticsearch at all, read the alternatives piece first; it may save you the whole cluster.