skip to content

Scout Full-Text Search

Scout mirrors an Eloquent model into a search index through the Searchable trait and model events, then queries it with Model::search(). Interviewers ask how the index stays in sync.

on this pageshow

explore

questions

5

In Laravel Scout, what does adding the Searchable trait to an Eloquent model do, and how does its search index stay in sync?

level: juniorimportance: must knowfreq 42%

answer

  1. a trait that boots an observer
  2. saved, deleted, restored, forceDeleted
  3. toSearchableArray() defaults to toArray()
  4. searchableAs() = scout.prefix + table
  5. search() hydrates models by key

basics

~20 s

The 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 s

Adding `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
<?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

for a junior

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.

for a middle

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.

for a senior

Show where sync breaks: writes that skip Eloquent events, oversized documents, and drafts leaking into results. Name shouldBeSearchable, searchIndexShouldBeUpdated and the repair tools.

for a principal

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.
open as a page

In Laravel Scout 11, how do the database and collection engines differ from Algolia, Meilisearch and Typesense, and when would you choose each?

level: middleimportance: should knowfreq 30%

basics

~20 s

The database and collection engines search your own tables with no separate index; Algolia, Meilisearch and Typesense keep an external index Scout must sync. Collection suits tiny datasets, database suits MySQL or PostgreSQL, external engines add typo tolerance and facets.

open as a page

In Laravel Scout, why can a mass update such as Recipe::where(...)->update([...]) leave the search index stale, and how do you repair it?

level: seniorimportance: should knowfreq 24%

basics

~20 s

A 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.

open as a page

In Laravel Scout, what changes when SCOUT_QUEUE is true, and why would you also turn on the after_commit option?

level: seniorimportance: should knowfreq 28%

basics

~10 s

With SCOUT_QUEUE true, Scout dispatches MakeSearchable and RemoveFromSearch jobs instead of calling the engine during the request. after_commit delays syncing until open transactions commit, so rolled-back or uncommitted rows never reach the index.

open as a page

In Laravel Scout 11, how do search()->where() and paginate() behave differently from the same calls on an Eloquent query builder?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

Scout's where() filters documents inside the search engine, so it only sees fields from toSearchableArray() and supports field comparisons, whereIn and whereNotIn, not closures or orWhere. paginate() returns a LengthAwarePaginator whose total comes from the engine.

open as a page