In Laravel Scout, what does adding the Searchable trait to an Eloquent model do, and how does its search index stay in sync?
answer
- a trait that boots an observer
- saved, deleted, restored, forceDeleted
- toSearchableArray() defaults to toArray()
- searchableAs() = scout.prefix + table
- search() hydrates models by key
basics
~20 sThe Searchable trait registers a Scout model observer that upserts the record into the search index on every Eloquent save and removes it on delete, using toSearchableArray() as the document and searchableAs() as the index name.
solid answer
~30 sAdding `Laravel\Scout\Searchable` boots a `ModelObserver` on the model. On `saved` it calls `searchable()`, an upsert of `toSearchableArray()` (by default the model's `toArray()`) into the index named by `searchableAs()` (the `scout.prefix` value plus the table name); on `deleted` it calls `unsearchable()`. `shouldBeSearchable()` keeps drafts out and `searchIndexShouldBeUpdated()` can skip irrelevant saves. `Recipe::search('basil')->get()` asks the engine for matching keys, then loads the real rows from the database, so the results are ordinary Eloquent models. The catch: sync rides on Eloquent model events, so a write that bypasses them, such as a query-builder `update()` or raw SQL, leaves the index stale.
code
php · 26 lines<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Recipe extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'ingredients' => $this->ingredients,
'cuisine' => $this->cuisine,
];
}
public function shouldBeSearchable(): bool
{
return $this->published_at !== null;
}
}go deeper
Recall that the Searchable trait is the opt-in, that saving a model indexes it, and that Model::search('term')->get() returns Eloquent models you can use like any query result.
Explain the observer events, the toSearchableArray() default of toArray(), the prefix-plus-table index name, and how search results are hydrated from the database by key.
Show where sync breaks: writes that skip Eloquent events, oversized documents, and drafts leaking into results. Name shouldBeSearchable, searchIndexShouldBeUpdated and the repair tools.
Frame model-event syncing as a convenience with a known gap, and decide which write paths in the product must go through Eloquent or schedule a reindex to keep search trustworthy.
## What the trait wires up **Laravel Scout** is a first-party package that mirrors Eloquent models into a search engine. You opt a model in by adding the `Laravel\Scout\Searchable` trait. When the model class boots, the trait's `bootSearchable()` method does three things: - registers a **`ModelObserver`** that listens to the model's Eloquent events; - adds a `SearchableScope`, which gives Eloquent queries and relations `searchable()` and `unsearchable()` macros; - adds `searchable()` / `unsearchable()` macros to collections of models. Nothing else in the app changes: you keep calling `save()`, `create()` and `delete()`, and the observer keeps the index in step. | Eloquent event | What the observer does | |---|---| | `saved` (create or update) | upserts the record, or removes it if `shouldBeSearchable()` now returns false | | `deleted` | removes the record, or keeps it flagged `__soft_deleted` when `scout.soft_delete` is true and the model soft-deletes | | `forceDeleted` | removes the record | | `restored` | upserts the record again | ## The document: toSearchableArray() Each model becomes one **document** in the index. Its content is whatever `toSearchableArray()` returns. The default implementation returns `$this->toArray()`, so every visible attribute, including appended ones, is sent. In a recipe catalogue you normally override it to send only what users search or filter on: 1. the `title`; 2. the `ingredients` text; 3. a `cuisine` or `vegetarian` field you want to filter with `where()`. Keeping the array small matters: every byte is shipped on every save, and hosted engines often bill or limit by record size. Returning an empty array tells the Meilisearch engine, for example, to skip that record. ## The index name: searchableAs() `searchableAs()` names the index. Its default is `config('scout.prefix')` followed by the model's table name, so a `Recipe` model lands in `recipes`, or in `staging_recipes` when `SCOUT_PREFIX=staging_`. The prefix lets several environments or tenants share one search service. Override `searchableAs()` to pick another name. The key stored with each document comes from `getScoutKey()`, the primary key by default. ## Deciding what gets indexed Two hooks run inside the observer on every save: - **`shouldBeSearchable()`** returns `true` by default. Return `false` for unpublished recipes and the observer removes them instead of indexing them. - **`searchIndexShouldBeUpdated()`** also returns `true` by default. Return `false` when only irrelevant columns changed, for example `return $this->wasRecentlyCreated || $this->wasChanged(['title', 'ingredients']);`. `withoutSyncingToSearch(fn () => ...)` pauses syncing for one model class while a closure runs, which is useful for bulk maintenance you plan to reindex afterwards. ## Searching returns Eloquent models `Recipe::search('basil')` returns a `Laravel\Scout\Builder`, not an Eloquent query. When you call `get()`: 1. the engine runs the text search and returns matching keys; 2. Scout loads those rows from your database with a `whereIn` on the key; 3. you receive an Eloquent `Collection` of `Recipe` models, in the engine's ranking order. Because the rows come from the database, a stale index can return fewer models than it matched, but never data that is no longer in your table. Use `raw()` when you want the engine's own response instead. ## Where automatic sync stops The observer only hears **Eloquent model events**. These writes do not fire them, so the index silently drifts: - mass updates and deletes on a query, such as `Recipe::where('cuisine', 'thai')->update([...])`; - `DB::table('recipes')` statements and raw SQL; - writes made by another application or a database console. The repair tools are the query macro `Recipe::where(...)->searchable()`, which chunks the matching rows and upserts them, and the `scout:import` command for a whole model. For the `database` and `collection` engines there is no separate index to drift, which is one reason small apps start there. ## Interview traps Interviewers probe the edges of this model with a few recurring follow-ups: - **"Does Scout index existing rows when you add the trait?"** No. The observer only reacts to future events; rows already in the table reach an external index through `scout:import`. - **"What about soft deletes?"** By default a soft-deleted recipe is removed from the index. With `scout.soft_delete` set to `true`, Scout keeps it with a `__soft_deleted` flag, and `withTrashed()` or `onlyTrashed()` on the search builder bring it back into results. - **"Can one model use a different engine?"** Yes: override `searchableUsing()` on that model. - **"Why are my results Eloquent models with relations missing?"** Hydration is a plain `whereIn`; eager-load through the search builder's `query()` callback.
- What happens in Laravel Scout when a published recipe is saved again as a draft, so shouldBeSearchable() now returns false?The observer checks `shouldBeSearchable()` on every save. When it returns false and `wasSearchableBeforeUpdate()` is true (its default), Scout calls `unsearchable()` and the recipe leaves the index. Calling `searchable()` directly on a model or an in-memory collection skips this check and indexes the record anyway.
- How would you stop Laravel Scout from reindexing a recipe when a save only changed its view_count column?Override `searchIndexShouldBeUpdated()` to return true only for relevant changes, for example `return $this->wasRecentlyCreated || $this->wasChanged(['title', 'ingredients']);`. The observer then skips the upsert on irrelevant saves. Deletes and restores still sync, because they do not consult this method.
saying these in an interview costs you the question
- Installing Scout indexes every Eloquent model automatically, with no trait needed.
- toSearchableArray() sends only the model's fillable attributes unless you override it.
- search()->get() returns the raw documents stored in the search engine.
- A query-builder update() across many recipes reindexes each of them.
- searchableAs() defaults to the model's class name rather than its table.