In Laravel Scout 11, how do search()->where() and paginate() behave differently from the same calls on an Eloquent query builder?
answer
- filters run inside the engine
- field must be in toSearchableArray()
- Scout 11 added comparison operators
- query() callback is not a filter
- LengthAwarePaginator with ?query= appended
basics
~20 sScout'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.
solid answer
~40 sOn a Scout builder, `where()` adds a field, operator and value that the engine applies to its own documents. Scout 11 accepts `=`, `!=`, `<`, `>`, `<=` and `>=`; Scout 10 stored only key and value pairs. The field must exist in `toSearchableArray()`, and Meilisearch also needs it in `filterableAttributes`. There is no `orWhere` or closure grouping, only `whereIn` and `whereNotIn`. `query(fn ($q) => ...)` runs on the Eloquent query that loads the matched keys, so with an external engine it is for eager loading, not filtering. `paginate(15)` returns a `LengthAwarePaginator` with the search term appended as `query`, and the engine knows nothing about the model's global scopes.
code
php · 16 lines<?php
use App\Models\Recipe;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
Route::get('/recipes/search', function (Request $request) {
$recipes = Recipe::search($request->input('q', ''))
->where('vegetarian', true)
->where('prep_minutes', '<=', 20)
->whereIn('cuisine', ['italian', 'greek'])
->query(fn (Builder $q) => $q->with('author'))
->paginate(12);
return view('recipes.search', ['recipes' => $recipes]);
});go deeper
Recall that Model::search() returns its own builder with where(), whereIn() and paginate(), and that paginate() works with $results->links() in Blade.
Explain that constraints run inside the engine on indexed fields, which operators Scout 11 supports, and why query() loads rows rather than filtering them.
Anticipate mismatched totals from global scopes or query() filters, engine-specific filterable and sortable settings, and the lack of OR logic.
Decide which filters belong in the search engine and which in SQL, and whether the product's filtering needs outgrow what Scout's builder can express.
## Two different builders `Recipe::where(...)` returns an **Eloquent builder** that compiles SQL. `Recipe::search('basil')` returns a **`Laravel\Scout\Builder`**, which collects constraints and hands them to the configured engine. The method names overlap on purpose, but the Scout builder: - sends constraints to the engine, which filters its own stored documents; - has a small vocabulary: `where`, `whereIn`, `whereNotIn`, `orderBy`, `take`, `within`, `query`, `options`, plus `withTrashed` and `onlyTrashed`; - has no `orWhere`, no nested closures and no joins. ## where() in Scout 11 `where($field, $operator, $value = null)` records an entry with `field`, `operator` and `value`. With two arguments the operator is `=`. Scout 11 accepts `=`, `!=`, `<`, `>`, `<=` and `>=`. In Scout 10 the `wheres` property was a plain key and value map, which is why older answers say Scout supports only equality. The upgrade guide records the change and warns authors of custom engines to read the new format. For a recipe catalogue: 1. `Recipe::search('basil')->where('vegetarian', true)` narrows to vegetarian dishes; 2. `->where('prep_minutes', '<=', 20)` keeps quick recipes; 3. `->whereIn('cuisine', ['italian', 'greek'])` allows a list of cuisines. Each field must be part of the document that `toSearchableArray()` built, because the engine only knows what it was sent. Engines add their own rules: - **Meilisearch** filters only on attributes listed in `filterableAttributes` and sorts only on `sortableAttributes`, configured under `index-settings` and pushed with `scout:sync-index-settings`; numeric comparisons also need values cast to numbers in `toSearchableArray()`. - **Typesense** filters on fields declared in the collection schema. - The **database** and **collection** engines turn Scout constraints into SQL on the table itself. ## query() is not a filter on external engines `query(fn (Builder $q) => $q->with('author'))` receives the Eloquent query that loads the matched keys from your database. With Algolia, Meilisearch or Typesense it runs **after** the engine has chosen the page of matches, so a filter there removes rows from a page the engine already filled, and pages come back short. When a `query()` callback is set, `paginate()` also recomputes the total by fetching every matching key from the engine and counting them in the database, which is correct but costly. Use it for eager loading. Only the database engine applies `query()` constraints to the search SQL itself, so there it can filter. ## paginate() and its limits | Call | Returns | Notes | |---|---|---| | `paginate($perPage)` | `LengthAwarePaginator` | total from the engine; `?query=` appended to links | | `simplePaginate($perPage)` | `Paginator` | next and previous only | | `get()` | Eloquent `Collection` | all matches up to the engine's limit | `paginate()` defaults `perPage` to the model's `getPerPage()` and the page name to `page`, then appends the search string as `query` so the links keep the search. Things that surprise people: - **Global scopes are invisible to the engine.** A scope that hides unpublished recipes applies when Scout loads the rows, but the engine's total still counts them. The docs advise against global scopes on models paginated through Scout, or recreating the constraint with `where()`. - `take()` sets a limit that the engine applies to the search. - Sorting needs `orderBy()` on a sortable field; the default order is the engine's relevance ranking. ## Putting it together A search page for recipes typically reads the term from the request, adds `where()` filters only for fields in the document, eager-loads relations with `query()`, and returns `paginate(12)` to a Blade view, where `{{ $recipes->links() }}` renders links that carry the search term. ## Interview traps - **"Just add `where('user_id', $id)` for any column."** Only if `user_id` is in `toSearchableArray()`, and on Meilisearch only if it is filterable. - **"Scout supports only equality."** True of Scout 10; Scout 11 added comparison operators, so version matters in the answer. - **"Filter in `query()`."** Fine on the database engine; on an external engine it gives short pages and an expensive recount. - **"Paginate normally and trust the total."** The total comes from the engine unless a `query()` callback forces a recount, so anything that hides rows after the engine matched them makes the numbers disagree.
- With Laravel Scout on Meilisearch, why might where('vegetarian', true) fail even though the column exists on the recipes table?Meilisearch only filters on attributes declared as `filterableAttributes`. Add the field under the model's `index-settings` in `config/scout.php`, run `php artisan scout:sync-index-settings`, and make sure `toSearchableArray()` actually sends `vegetarian`. The database column itself is irrelevant, because the engine filters its own documents.
- How would you express 'Italian OR under 20 minutes' in a Laravel Scout search?The Scout builder has no `orWhere` or grouping, so you cannot express it with `where()` calls. Options are an engine-specific filter string passed through the `search()` callback or `options()`, precomputing a searchable field that captures the rule, or running two searches and merging them in PHP.
saying these in an interview costs you the question
- Scout's where() can filter on any column of the table, even if it is not indexed.
- Scout's where() only ever supports equality checks.
- Filtering inside query() narrows results with Meilisearch the same as with SQL.
- The engine applies the model's global scopes when counting results.
- The Scout builder supports orWhere and closure groups like Eloquent.