In Laravel Scout, why can a mass update such as Recipe::where(...)->update([...]) leave the search index stale, and how do you repair it?
answer
- no models loaded, no events
- query macro searchable() chunks by key
- scout:import upserts, --fresh flushes first
- scout:flush empties the index
- ghost hits vanish on hydration
basics
~20 sA query-builder update runs one SQL statement without loading models, so no Eloquent events fire and Scout's observer never runs. Repair it by chaining searchable() onto the same query, or with scout:import, adding --fresh to clear orphans.
solid answer
~30 sScout only syncs from Eloquent model events, and a query-builder `update()` or `delete()`, `DB::table()`, raw SQL and outside writers fire none. For a targeted fix, chain the macro on the query: `Recipe::where('cuisine', 'thai')->searchable()` chunks the rows by key (500 by default, `scout.chunk.searchable`) and upserts them; `unsearchable()` removes them. For a whole model, `php artisan scout:import "App\Models\Recipe"` upserts every row but never deletes orphans; `--fresh` flushes the index first, and `scout:flush` only empties it. Orphans are subtle: search hydrates hits from the database, so a deleted recipe vanishes from `get()` while the engine's total still counts it, and pages come back short.
code
php · 13 lines<?php
use App\Models\Recipe;
// Bulk change that fires no model events...
Recipe::where('cuisine', 'thai')->update(['spicy' => true]);
// ...so push the same rows to the index explicitly
Recipe::where('cuisine', 'thai')->searchable();
// Remove from the index BEFORE deleting in bulk
Recipe::where('archived', true)->unsearchable();
Recipe::where('archived', true)->delete();go deeper
Recall that only model saves and deletes sync to Scout, and that scout:import loads existing rows into the index.
Explain why query-builder writes skip events, how the searchable() and unsearchable() query macros chunk by key, and the difference between import, --fresh and flush.
Diagnose drift from its symptoms, such as short pages and missing new records, and plan a repair that avoids an empty index during peak traffic.
Set a policy for bulk writes to searchable models and decide whether periodic reconciliation or a rebuild-and-swap process is worth its cost for the catalogue's size.
## Why the index drifts Scout keeps an external index in sync through an **observer** on the model's `saved`, `deleted`, `forceDeleted` and `restored` events. Those events fire only when Eloquent works with a **model instance**. Several common writes never create one: - `Recipe::where('cuisine', 'thai')->update(['spicy' => true])`, a single `UPDATE` statement; - `Recipe::where('archived', true)->delete()`, a single `DELETE`; - `DB::table('recipes')` statements and raw SQL; - data changed by another service, a migration's data fix or a database console; - anything run inside `Recipe::withoutSyncingToSearch(...)`. The database is right and the index is wrong, and nothing reports it. With the `database` and `collection` engines this problem does not exist, because they search the table itself. ## Targeted repair with the query macros The `Searchable` trait adds `searchable()` and `unsearchable()` macros to Eloquent queries and relations. Chain them onto the same constraint you used for the update: 1. `Recipe::where('cuisine', 'thai')->searchable()` runs `chunkById` over the matching rows, 500 per chunk by default (`scout.chunk.searchable`), keeps those for which `shouldBeSearchable()` is true, and upserts them; 2. `Recipe::where('archived', true)->unsearchable()` removes matching rows from the index, which only works **before** the rows are deleted from the database; 3. `$user->recipes()->searchable()` does the same through a relationship. With `SCOUT_QUEUE=true` each chunk becomes a queued job. The pattern for a bulk fix is to run the update, then the macro, or to wrap the work in `withoutSyncingToSearch()` and reindex once at the end. ## Whole-model repair with Artisan | Command | Effect | |---|---| | `scout:import "App\Models\Recipe"` | upserts every row in chunks; stale documents for deleted rows stay | | `scout:import ... --fresh` | calls `removeAllFromSearch()` first, then imports | | `scout:import ... --chunk=1000` | overrides `scout.chunk.searchable` | | `scout:flush "App\Models\Recipe"` | removes every document for the model and imports nothing | | `scout:queue-import "App\Models\Recipe"` | splits the key range into chunks and queues one job per chunk | `scout:import` goes through `makeAllSearchable()`, so it also respects `SCOUT_QUEUE`, and it uses `makeAllSearchableUsing()` if you define it to eager-load relations. Note that `--fresh` leaves the index **empty until the import finishes**, which users see as missing results; on a large catalogue schedule it off-peak or import into a new index name first. ## Orphans and short pages When you search, Scout asks the engine for matching keys and loads those rows with a `whereIn`. A document whose row was deleted by a mass `delete()` therefore disappears from `get()` without any error. Pagination shows the drift: `paginate()` takes its total from the engine, which still counts the orphan, while the page holds only the rows that loaded. Symptoms of drift are: - pages with fewer recipes than the page size; - a result count higher than the recipes users can open; - new or edited recipes missing from results until someone saves them again. ## Preventing it Keep writes to searchable models on Eloquent paths where you can, and where a bulk statement is the right tool, follow it with the matching `searchable()` or `unsearchable()` call. Treat `scout:import --fresh` as a recovery or schema-change tool, for example after changing a Typesense collection schema, rather than a routine deploy step. ## A worked incident A recipe site adds a `spicy` filter and backfills it with one mass `update()`. Search results ignore the filter for existing recipes, while new recipes behave. The diagnosis follows directly from the model above: 1. the backfill fired no `saved` events, so no documents changed; 2. new recipes are saved through Eloquent, so their documents carry the field; 3. filtering on `spicy` therefore only finds recipes created after the deploy. The fix is `Recipe::query()->searchable()`, or `scout:import` for the whole model, run once after the backfill. Because the change only added a field, no flush is needed: the upserts overwrite each document in place.
- Why doesn't a plain scout:import remove recipes that were deleted with a bulk delete()?`scout:import` walks the rows that exist in the table and upserts them. A deleted row is no longer in the table, so the import never sees it and never deletes its document. Use `--fresh`, or `scout:flush` before importing, to clear orphans, accepting that the index is empty until the import finishes.
- How can you eager-load a recipe's tags for Laravel Scout's batch import so toSearchableArray() does not query per row?Define `makeAllSearchableUsing(Builder $query)` on the model and return `$query->with('tags')`; `scout:import` uses that query. For syncs triggered by saves, `makeSearchableUsing(Collection $models)` can call `$models->load('tags')` on each batch just before it is indexed.
saying these in an interview costs you the question
- Recipe::where(...)->update() fires the updated event for each matching row.
- scout:import deletes documents whose rows no longer exist.
- A stale index makes search return recipes that were deleted from the table.
- scout:flush reimports the model after clearing its index.
- You can call unsearchable() on a query after the rows are already deleted.