Skip to content
Search

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.

4 min read Updated Sep 4, 2026
Wiring Elasticsearch into Laravel: Scout, Sync and Zero-Downtime Reindexing

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.

Model observers syncing to the index, queries through the DSL, results hydrated back

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.

Keep reading

Related articles

Search 4 min read

Elasticsearch from the Inverted Index Up

The three machines under the API: inverted indexes (why search is fast and updates are weird), analyzers with a Turkish-aware example, permanent mappings, and BM25 relevance in one honest paragraph.