Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Laravel Scout SQLite FTS5 Driver

Full-text search for your Eloquent models, powered by SQLite’s own FTS5 engine. A Laravel Scout driver that keeps the search index right next to your data.

No daemon, no API key, no second datastore to keep in sync.

Customer::search('kowalsky')->paginate(20);

That query finds Jan Kowalski despite the typo, because search runs as a cascade of progressively fuzzier passes and stops at the first one that matches. Results come back ranked by BM25, ordered and paginated in SQL.

Install

composer require mtk3d/scout-sqlite-fts5
SCOUT_DRIVER=sqlite-fts5
php artisan scout:fts5-create
php artisan scout:import "App\Models\Customer"

There is no migration to publish. Index tables are derived from the models that use Laravel\Scout\Searchable, and scout:fts5-rebuild recreates them from scratch whenever you need.

Where to go next

How it works is the one to read if you want to know what you are running: what the index looks like, what each of the four passes does, why the substring pass has a threshold, and how results are ranked.

Filtering and ordering covers where(), whereIn(), sorting, what pagination guarantees, soft deletes, and how models with string keys differ.

Configuration is the reference: every option, what it costs, whether it needs a rebuild, and how to swap the normalizer or any other service.

Recipes has the patterns — a search box, telling a user the match was fuzzy, indexing relations, multi-tenancy, bulk imports, NativePHP, and testing search in your own application.

Troubleshooting lists every error the package throws with what to do about it, plus the behaviour that looks like a bug and is not.

Requirements

  • PHP 8.3+
  • Laravel 12 or 13, with Scout 11
  • SQLite compiled with FTS5 — the default in every mainstream PHP build since 3.9
php -r 'echo (new PDO("sqlite::memory:"))->exec("CREATE VIRTUAL TABLE t USING fts5(c)") === false ? "missing" : "ok";'

The package is MIT licensed and lives on GitHub.

How it works

← back to the README

The index

Every searchable model gets one FTS5 virtual table, named after its searchableAs() value plus the configured suffix — customers becomes customers_fts. It is created in your existing database, on the connection Laravel is already configured with, next to the table it indexes.

CREATE VIRTUAL TABLE "customers_fts" USING fts5(
    "content",
    "tenant_id" UNINDEXED,
    tokenize='unicode61 remove_diacritics 2'
)

Everything toSearchableArray() returns is flattened, joined with spaces, normalized and stored in the single content column. FTS5 tokenizes that column and maintains the inverted index; the driver never has to.

Values from searchableFilters() become UNINDEXED columns: stored and filterable, but not searched.

Where the key goes

Models with an integer key store it in the table’s implicit rowid. That makes an update or delete an index lookup:

DELETE FROM "customers_fts" WHERE rowid IN (7, 9)

Models with a string key — UUID, ULID — get an explicit doc_id UNINDEXED column instead, because a rowid has to be an integer. FTS5 has nowhere to put a secondary index, so deleting by doc_id is a table scan. It works fine at small and medium scale; it is worth knowing before you bulk-import a million rows.

Scout’s usual write path applies: models are indexed on save, removed on delete, and scout.queue moves that work to a queue if you want it out of the request.

The search cascade

A query is answered by four passes, tried strictest first. The first pass that matches anything wins and the rest never execute. An exact query costs one indexed MATCH; only a query that finds nothing pays for the fuzzier interpretations below it.

Take the query jan kowalsky, against a document containing Jan Kowalski.

1. prefix — every word, as a prefix

"jan"* AND "kowalsky"*

Matches exact words and partial ones, so search works as the user types. Here it fails: nothing starts with kowalsky.

2. typo — every word, shortened

"jan"* AND "kowals"*

Each word loses its last typo.trim characters (two by default), never dropping below typo.min_prefix. This is the cheapest useful typo tolerance there is: most misspellings are in the ending, and a shortened prefix still uses the index. Here it matches.

Words too short to shorten come back unchanged — the driver notices that the shortened query is identical to the prefix query and skips the pass rather than running it twice.

3. any — one word is enough

"jan"* OR "kowalsky"*

For when someone got one word of several wrong, or typed a word that simply is not in the document. Skipped for single-word queries, where it would be identical to the prefix pass.

4. trigram — substrings, anywhere

content LIKE '%kow%' OR content LIKE '%owe%' OR …

The pass that catches what a prefix query structurally cannot. kowerlski shares no usable prefix with kowalski — the typo is in the middle — but it shares most of its three-character substrings.

This is a scan. It is last for that reason, and it only runs when the three indexed passes all came back empty.

Why there is a threshold

A naive substring fallback matches far too much. The query zupelnie inne slowa has nothing to do with Jan Kowalski, but kowalski contains owa, so a single shared trigram is enough to “match”. Ask enough three-letter questions and everything answers one of them.

So a document has to contain a share of a single word’s substringstrigram.min_ratio, 40% by default — not just one of any word’s:

Queryvs Jan KowalskiResult
kowerlski3 of 7 substrings of one word (43%)matches
zupelnie inne slowabest word: 1 of 3 (33%)no match

Measuring per word rather than across the whole query is what makes those two separable. A misspelled word keeps most of its own substrings; an unrelated query only ever shares a stray one or two of any single word’s.

Raise the ratio if nonsense still comes back, lower it to catch heavier misspellings.

Ranking

Within an indexed pass, documents are ordered by FTS5’s BM25 score: a term is worth more in a short document than in a long one, and rarer terms count for more. A customer whose name is “Kowalski” outranks one who has “Kowalski” buried in a paragraph of notes.

The substring pass has no BM25 score to speak of, so its documents are ranked by how many of the query’s substrings they contain.

The order is decided in SQL and carried through hydration: search()->get() returns models in relevance order, not in whatever order the database felt like returning rows.

No stemmer

FTS5 is the search engine here. It owns the index, the tokenizing and the ranking; this package decides what to put in, what to ask, and in what order to ask it. That division is worth keeping in mind, because it explains what is missing as much as what is there.

What is missing is stemming — reducing inflected forms to a shared root, so that a search for one form finds the others:

biegał, biegnie, biegami  →  bieg
engineering, engineer     →  engin

Nothing in this package does that. Words go into the index as the tokenizer split them and come out the same way. Search here is string matching over an inverted index, made forgiving by trying several shapes of the same query — not linguistic analysis.

That is a deliberate limit rather than an oversight. A stemmer is per-language, has to be shipped and maintained for each one, and has to agree exactly between indexing and searching or the two stop meeting. Drivers that build their own index carry a dozen of them for this reason.

What you get instead

FTS5 ships one stemmer, for English, and you can turn it on:

'tokenizer' => 'porter unicode61 remove_diacritics 2',

For everything else, the typo pass turns out to approximate suffix stripping by accident. It shortens each word and matches the remainder as a prefix — and inflection mostly changes endings, so the two land in a similar place. Against an index containing Wymiana rozrządu:

QueryFound by
wymianaprefix
wymianytypo
wymianętypo
wymianietypo
rozrząduprefix
rozrządemtypo

All six reach the document, with no stemmer and no configuration. Polish is not among the languages Snowball stems, so for a language like this the accident is worth more than the real thing would be.

Do not mistake it for one, though. Shortening is blind where a stemmer knows roots, so words that merely look alike are reached too: searching kowalski finds Kowalczyk, because they share three of six substrings and that clears the threshold. Both behaviours are pinned in the edge cases.

Normalization

Text is normalized on the way into the index and on the way into a query, so the two always agree.

The default normalizer lowercases and folds Latin diacritics: Polish, German, Nordic, Czech, Hungarian, Romanian and Romance alphabets all collapse to ASCII. Kraków is stored as krakow, and a search for krakow, Kraków or KRAKÓW all reduce to the same thing.

FTS5’s unicode61 remove_diacritics 2 tokenizer already folds diacritics inside the index, so why fold again in PHP? Because the substring pass uses LIKE, which never goes through the tokenizer. Without the PHP-side fold, krakow would find Kraków in the indexed passes and lose it in the fallback.

Swapping the normalizer →

Query safety

Words are handed to FTS5 as quoted phrases:

"jan"* AND "kowalsky"*

FTS5 parses the contents of a MATCH as a query language of its own, so a user typing AND, NEAR, - or ( would otherwise be writing queries rather than searching for words. Quoting each word means kowalski OR nowak searches for the literal word or, and a stray " is a character rather than a syntax error.

The match expression itself is bound as a statement parameter, as is every filter value.

Filtering and ordering

← back to the README

Filters

Scout’s where(), whereIn() and whereNotIn() all work. Where they are answered depends on whether the column is part of the index.

Indexed filters

Declare the columns you filter on most, and they are stored in the index table itself:

class Customer extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return ['name' => $this->name, 'city' => $this->city];
    }

    public function searchableFilters(): array
    {
        return ['tenant_id' => $this->tenant_id, 'status' => $this->status];
    }
}
Customer::search('kowalski')->where('tenant_id', 1)->get();
Customer::search('kowalski')->whereIn('status', ['active', 'pending'])->get();

Each key becomes an UNINDEXED column on the virtual table — stored and filterable, but not searched. No join is needed, so this is the faster path.

Two things to know:

  • The method is also called on a bare model instance to work out the table’s columns. Return the same keys regardless of the model’s state; the values may be null.
  • Adding a column means rebuilding. FTS5 tables have no ALTER TABLE, so there is nowhere to put a new column. The driver notices the mismatch on the next write and tells you rather than failing on an insert:
php artisan scout:fts5-rebuild "App\Models\Customer"

Filters on model columns

Columns you did not declare still work:

Customer::search('kowalski')->where('archived_at', null)->get();

Because the index lives in the same SQLite file as your data, the driver joins the model’s own table to answer these. It costs a join, and it is why the index has to share a connection with your models.

A filter on a column that exists in neither place throws:

Cannot filter search results by [statuss]: it is not an indexed filter column of
[App\Models\Customer]. Declare it in App\Models\Customer::searchableFilters() and
rebuild the index. Available columns: tenant_id, status, id, name, city, …

Silently ignoring an unknown filter would mean quietly returning results the caller asked you to exclude, which is worse than an exception.

Operators

Customer::search('kowalski')->where('tenant_id', '>', 3)->get();

Any operator SQLite understands works, on both indexed and joined columns.

Ordering

Without an explicit order, results come back by relevance — see ranking.

Ask for an order and you get it:

Customer::search('kowalski')->latest()->get();
Customer::search('kowalski')->orderBy('name')->get();

Ordering columns are resolved against the model’s table, so anything you can sort a query by, you can sort a search by.

Pagination

Customer::search('kowalski')->latest()->paginate(20);

Ordering and slicing both happen in SQL, in that order.

This matters more than it sounds. An engine that pages first and sorts afterwards is sorting one arbitrary slice per page: records show up twice, or never, and the pages do not add up to the result set. Because the order reaches the index query here, page 2 is the second page of the order you asked for, and the count reported by the paginator is every document that matched — not the size of the current page.

simplePaginate(), cursor() and keys() work as usual.

Soft deletes

Turn on Scout’s support:

// config/scout.php
'soft_delete' => true,

Models using SoftDeletes then get a __soft_deleted column in their index. Trashed records stay indexed but drop out of results:

Post::search('rozrząd')->get();               // only live records
Post::search('rozrząd')->withTrashed()->get();  // everything
Post::search('rozrząd')->onlyTrashed()->get();  // only trashed

Restoring a model brings it back into results.

Turning this on changes the index schema, so rebuild after flipping it.

Models with string keys

UUID and ULID models work, with one difference worth knowing: their key goes in an explicit doc_id column instead of the table’s rowid, and FTS5 cannot index it. Deletes and updates scan.

For most applications this is invisible. For a bulk import of hundreds of thousands of rows it is the difference between seconds and minutes — import into a freshly created index rather than overwriting an existing one, so there is nothing to delete first:

php artisan scout:fts5-rebuild "App\Models\Article"

More on bulk imports →

Configuration

← back to the README

php artisan vendor:publish --tag=scout-fts5-config

Publishing is optional — the packaged defaults apply either way. Every option below lives in config/scout-fts5.php.

Options marked rebuild change the shape or contents of the index, so run scout:fts5-rebuild after touching them.

connection

'connection' => env('SCOUT_FTS5_CONNECTION'), // null = the default connection

The SQLite connection the index tables live on. null uses the application’s default, which puts the index in the same file as your data — the usual arrangement, and the one where everything works.

The driver checks the connection’s driver at boot and throws if it is not SQLite, rather than failing halfway through a query.

Keeping the index in its own file

Pointing this at a second SQLite connection puts the whole index in a separate file. That has real appeal: the file can be excluded from backups, deleted and rebuilt without touching application data, and kept out of whatever replicates your database.

Search and filters on indexed columns work exactly as before. What stops working is anything that needs the model’s own table, because SQLite cannot join across connections:

Customer::search('kowalski')->get();                       // works
Customer::search('kowalski')->where('tenant_id', 1)->get(); // works — indexed filter
Customer::search('kowalski')->latest()->get();              // throws
Customer::search('kowalski')->where('status', 1)->get();    // throws — not an indexed filter

The driver detects the mismatch and says so, rather than letting SQLite report a table it was never going to find.

Take this arrangement if you sort by relevance and declare every column you filter on in searchableFilters(). Otherwise leave the index where your data is.

suffix — rebuild

'suffix' => '_fts',

Appended to searchableAs() to name the index table. customers becomes customers_fts.

Changing it does not rename anything; it points the driver at differently named tables. Create them first, or the next search will simply find nothing.

tokenizer — rebuild

'tokenizer' => 'unicode61 remove_diacritics 2',

Passed verbatim into CREATE VIRTUAL TABLE … tokenize='…'. The default folds diacritics and splits on non-alphanumerics.

Useful variations:

TokenizerEffect
unicode61 remove_diacritics 2the default
porter unicode61 remove_diacritics 2English stemming — engineering matches engineer
unicode61 remove_diacritics 2 tokenchars '-_'keeps hyphens and underscores inside words
trigramsubstring matching in the index itself; requires queries of 3+ characters

See SQLite’s tokenizer documentation for the full grammar.

auto_create

'auto_create' => true,

Creates a missing index table the first time a model is indexed. Convenient in development and in apps that ship a database file with no migration step, such as NativePHP builds.

Set it to false to manage the schema explicitly. Indexing a model with no table then throws instead of creating one.

normalizer — rebuild

'normalizer' => DiacriticsNormalizer::class,

Applied to both indexed content and incoming queries, so the two always agree. The default lowercases and folds Latin diacritics.

Normalization

Write your own by implementing the contract:

namespace App\Search;

use ScoutFts5\Contracts\Normalizer;

class StreetNormalizer implements Normalizer
{
    public function normalize(string $text): string
    {
        return str_replace(['strasse', 'straße'], 'str', mb_strtolower($text));
    }
}

Point the config at it, or bind the contract if it needs dependencies:

$this->app->bind(Normalizer::class, StreetNormalizer::class);

Every other service is resolved from the container too — ScoutFts5\Indexer, ScoutFts5\Seeker, ScoutFts5\Support\Schema and ScoutFts5\Engine can all be swapped the same way.

passes

'passes' => [
    'prefix' => true,
    'typo' => true,
    'any' => true,
    'trigram' => true,
],

Which of the four passes may run. They are always tried strictest first, and the first one that matches wins.

Turning passes off makes search stricter and faster, since a query that finds nothing stops sooner:

  • any => false keeps multi-word queries strict. Searching jan kowalski will not fall back to everyone named Jan.
  • trigram => false means search never scans. Worth it on large tables, or anywhere a query that finds nothing must stay cheap.

Disabling prefix is possible but rarely sensible — it is the pass that answers ordinary queries.

typo

'typo' => [
    'trim' => 2,
    'min_prefix' => 3,
],

trim is how many characters come off the end of each word; min_prefix is the shortest prefix the pass will search for.

Raising trim catches worse endings at the cost of precision: at 4, searching kowalxyzw finds Kowalski, and so does searching kowalcokolwiek.

trigram

'trigram' => [
    'size' => 3,
    'max_grams' => 24,
    'min_ratio' => 0.4,
],

Tuning for the substring pass. size is the substring length, max_grams caps how many substrings one query may produce, and min_ratio is the share of a single word’s substrings a document must contain to count as a match.

min_ratio is the one worth tuning. Too low and nonsense queries come back with results; too high and the pass stops catching the misspellings it exists for. See why there is a threshold for how the numbers actually fall.

model_paths and models

'model_paths' => [app_path('Models')],
'models' => [],

Where the scout:fts5-* commands look for searchable models when you do not name one. Paths are scanned recursively for classes that use Laravel\Scout\Searchable.

List classes in models to skip scanning entirely — faster, and explicit about what gets an index:

'models' => [
    App\Models\Customer::class,
    App\Models\Invoice::class,
],

When models is non-empty, model_paths is ignored.

Recipes

← back to the README

The whole point of the cascade is that a search box can be forgiving without you writing any of the forgiveness. A Livewire component needs nothing special:

class CustomerList extends Component
{
    use WithPagination;

    public string $search = '';

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function render(): View
    {
        $customers = $this->search === ''
            ? Customer::query()->latest()->paginate(20)
            : Customer::search($this->search)->paginate(20);

        return view('livewire.customers.list', compact('customers'));
    }
}

Note what is not there: no ->latest() on the search branch. Sorting a search by date throws away the ranking — see ordering.

Telling the user you guessed

The result object reports which pass answered, so you can say so instead of silently returning something the user did not ask for. Scout’s withRawResults() hands it to you without running the search twice:

$approximate = false;

$customers = Customer::search($this->search)
    ->withRawResults(function ($result) use (&$approximate) {
        $approximate = $result->pass() !== null && $result->pass() !== 'prefix';
    })
    ->paginate(20);
@if ($approximate)
    <p>No exact match for <strong>{{ $term }}</strong> — showing the closest results.</p>
@endif

In practice prefix means “found it”, and anything else means “found something like it”. withRawResults() runs on the paginating methods; outside them, raw() returns the same object.

Indexing relations

toSearchableArray() is just an array. Anything you can load, you can index:

public function toSearchableArray(): array
{
    return [
        'name' => $this->name,
        'phone' => $this->phone,
        'vehicles' => $this->vehicles
            ->map(fn ($v) => "{$v->make} {$v->model} {$v->registration}")
            ->implode(' '),
    ];
}

Now a registration number finds its owner. Two things to keep in mind:

Eager load during imports, or scout:import will run a query per model:

public function makeAllSearchableUsing(Builder $query): Builder
{
    return $query->with('vehicles');
}

Reindex the parent when the relation changes — Scout only watches the model that owns the index:

class Vehicle extends Model
{
    protected static function booted(): void
    {
        static::saved(fn (Vehicle $vehicle) => $vehicle->customer?->searchable());
        static::deleted(fn (Vehicle $vehicle) => $vehicle->customer?->searchable());
    }
}

Keeping records out of the index

Scout’s shouldBeSearchable() decides per record:

public function shouldBeSearchable(): bool
{
    return $this->status !== 'draft';
}

Records that stop qualifying are removed from the index on their next save, so a draft disappears from search without being deleted.

Multi-tenancy

Declare the tenant key as a filter so it never leaves the index:

public function searchableFilters(): array
{
    return ['tenant_id' => $this->tenant_id];
}

Then apply it everywhere, in one place rather than at every call site. Scout’s builder is macroable:

// in a service provider
use Laravel\Scout\Builder;

Builder::macro('forTenant', function () {
    return $this->where('tenant_id', auth()->user()->tenant_id);
});
Customer::search($term)->forTenant()->paginate(20);

Filtering inside the index means the tenant check happens before ranking and pagination, so counts and pages are right — and a cross-tenant record never reaches PHP.

If you prefer one index per tenant, override searchableAs() instead and let each tenant have its own virtual table. Turn auto_create on so new tenants get theirs on first write.

Bulk imports

Importing into an existing index makes every row delete whatever was there before. Rebuilding skips that entirely:

php artisan scout:fts5-rebuild "App\Models\Customer"

That drops the table, recreates it, runs scout:import and optimizes. For a large import, also widen Scout’s chunk and take the work off the request:

// config/scout.php
'chunk' => ['searchable' => 2000],

Rebuilding is destructive while it runs: the index is empty between the drop and the end of the import, so searches during that window find nothing. On a live application, import into a differently suffixed index and switch scout-fts5.suffix when it finishes.

After any large import, merge the index segments:

php artisan scout:fts5-optimize

It rewrites the index into as few segments as possible. Worth doing after tens of thousands of writes; pointless after a handful.

NativePHP and other shipped databases

Desktop builds have no migration step and no deploy, which suits an index derived from models rather than migrations. Leave auto_create on and the tables appear on first write.

If your build rewrites the database path at runtime, point the application at it before rebuilding:

#[AsCommand(name: 'native:search:rebuild')]
class NativeSearchRebuild extends Command
{
    public function handle(): int
    {
        (new NativeServiceProvider($this->laravel))->rewriteDatabase();

        return $this->call('scout:fts5-rebuild');
    }
}

The exact API depends on your NativePHP version; the point is that the rebuild has to run against the same database file the app will open.

Testing search in your application

The index is an ordinary part of the database, so it behaves under RefreshDatabase like everything else — with one wrinkle: RefreshDatabase rolls back a transaction, and a virtual table created inside that transaction goes with it. With auto_create on, the table is simply recreated on the next write, so tests pass either way.

To assert on ranking rather than membership, compare the whole ordered list:

public function test_an_exact_name_outranks_a_mention_in_notes(): void
{
    $exact = Customer::create(['name' => 'Kowalski']);
    $mention = Customer::create(['name' => 'Nowak', 'notes' => 'polecony przez Kowalskiego']);

    $this->assertSame(
        [$exact->id, $mention->id],
        Customer::search('kowalski')->keys()->all()
    );
}

To keep a factory out of the index, wrap it:

Customer::withoutSyncingToSearch(fn () => Customer::factory()->count(1000)->create());

Edge cases

← back to the README

What happens at the boundaries. Everything on this page is pinned by a test in tests/EdgeCaseTest.php, so the page cannot drift from the behaviour — if you find a case that contradicts what is written here, that is a bug in the package rather than in the documentation.

Text and languages

0 is indexed; empty strings are not

toSearchableArray() values are dropped when they are null or '', and kept otherwise. A zero survives:

Customer::create(['name' => '0', 'city' => 'Zero', 'notes' => '']);
// indexed content: "0 zero"

This matters more than it sounds. array_filter() without a callback — the obvious way to drop empty values — also drops "0", 0 and false. House numbers, meter readings, account balances and status codes all vanish from the index that way.

CJK matches from the start of a run, not the middle

The unicode61 tokenizer splits on characters that are neither letters nor digits. A run of CJK contains none, so it becomes a single token:

Customer::create(['name' => '東京都渋谷区']);

Customer::search('東京')->get();  // 1 — a prefix of the token
Customer::search('渋谷')->get();  // 0 — the middle of it

The substring pass would normally catch the second case, but 渋谷 is two characters and the pass skips words shorter than trigram.size. Lowering the size brings it back:

'trigram' => ['size' => 2],

With that, 渋谷 matches through the substring pass. The cost is a broader and slower last pass for every language, so set it only if you index CJK.

The trigram tokenizer is the other option and behaves differently from what its name suggests here: it indexes substrings, so mid-word matching works for Latin text, but SQLite requires queries of at least three characters — which puts two-character CJK words out of reach entirely.

Inflected forms usually match, but not because anything understands them

There is no stemmer — see how it works. The typo pass shortens each word and matches the remainder as a prefix, which happens to land close to suffix stripping:

Customer::create(['name' => 'Serwis', 'notes' => 'Wymiana rozrządu']);

// all six find it
foreach (['wymiana', 'wymiany', 'wymianę', 'wymianie', 'rozrząd', 'rozrządem'] as $form) { }

The cost of the same blindness is that lookalikes are reached too:

Customer::create(['name' => 'Kowalczyk']);

Customer::search('kowalski')->raw()->pass();   // 'trigram' — and it matches

kowalski and kowalczyk share three of six substrings, which clears the threshold. A stemmer would keep the two roots apart; the cascade only compares characters. Raise trigram.min_ratio if this trade is wrong for your data.

Alphabets with no ASCII equivalent work normally

Cyrillic, Greek and the rest tokenize like Latin, and prefix matching works:

Customer::create(['name' => 'Ковальский Ян']);

Customer::search('ковальский')->get();  // 1
Customer::search('коваль')->get();      // 1

The default normalizer folds Latin diacritics and lowercases everything; it leaves other scripts alone, and unicode61 remove_diacritics 2 handles their case folding inside the index.

Punctuation splits words, which is usually what you want

A phone number is indexed as written, and found either way it is typed:

Customer::create(['name' => 'Nowak', 'notes' => '+48 601-234-567']);

Customer::search('601-234')->raw()->pass();     // 'prefix'
Customer::search('601234567')->raw()->pass();   // 'trigram'

Typed with separators, the digit groups are words and the strict pass answers. Typed as one run, no token matches — and the substring pass recognises 601, 234 and 567 inside the content, which is enough to clear the threshold.

Queries

A short query that matches nothing finds nothing

Customer::create(['name' => 'Kowalski']);

Customer::search('ko')->raw()->pass();   // 'prefix' — still prefixes a real token
Customer::search('xy')->raw()->pass();   // null

Words shorter than trigram.size have no substrings to fall back on, so the cascade runs out instead of matching everything. A two-character query is either a real prefix or nothing.

A query with no words returns nothing

' ... ' and '' produce no tokens, so no search runs at all. The result is empty with a total of zero — not an error, and not everything.

FTS5 syntax is searched for, not executed

See decision 7. kowalski OR nowak looks for the literal word or, which is why it falls through to the any pass rather than being answered by the strict one.

Pagination and limits

A page past the last one reports the real total

$page = Customer::search('kowalski')->paginate(10, 'page', 99);

$page->items();  // []
$page->total();  // 1

take() is overridden by a page size

Customer::search('kowalski')->take(1)->get();          // 1
Customer::search('kowalski')->take(1)->paginate(10);   // 3

take() sets the size of an unpaginated search. Once a page size is given it wins — the same way Scout’s other engines behave.

An empty whereIn matches nothing

Customer::search('kowalski')->whereIn('tenant_id', [])->get();  // 0

This is SQL’s semantics rather than a decision of this package, and it is the safe direction: an empty allow-list allows nothing. Guard the call site if you meant “no filter”.

The index itself

Searching a model with no index table returns nothing

It does not throw. A missing table means an empty result, so a dropped or not-yet-created index degrades to “no results” rather than breaking every page in the application. Indexing into a missing table does throw when auto_create is off — see decision 8 for why the two directions differ.

An index whose columns no longer match its model throws

Adding a key to searchableFilters(), or turning on scout.soft_delete, changes the table’s shape. FTS5 has no ALTER TABLE, so the driver detects the mismatch on the next write and names the rebuild command rather than letting the insert fail on no such column.

A model that stops being searchable is removed

An empty toSearchableArray() is Scout’s way of saying “keep this out of results”. Anything already indexed for that model is deleted on its next save, so the record disappears from search without being deleted from the database.

Virtual tables do not survive a transaction rollback

RefreshDatabase wraps each test in a transaction, and CREATE VIRTUAL TABLE inside a transaction is rolled back with it. With auto_create on — the default — the table is simply recreated on the next write, so tests pass either way. With it off, create the tables in your test setup.

Table prefixes are applied to the index too

A connection with a prefix gets app_customers_fts, and every hand-written fragment — CREATE, MATCH, bm25(), PRAGMA — goes through the same naming as the query builder. This is exercised end to end in tests/TablePrefixTest.php, because a mismatch here only appears on connections that set a prefix, which is precisely when it is hardest to debug.

Troubleshooting

← back to the README

Errors this package throws

Every one of them is a ScoutFts5\Exceptions\ScoutFts5Exception.

The FTS5 index table […] does not exist

Run php artisan scout:fts5-create to create it, or enable scout-fts5.auto_create.

Exactly what it says: something tried to index a model whose table has not been created, with auto_create turned off.

php artisan scout:fts5-create "App\Models\Customer"

Note that searching a model with no index table is not an error — it returns no results. A search that has to fail loudly on a missing index is a search that breaks the page for every user the moment a table is dropped.

The FTS5 index table […] is missing columns declared by […]

FTS5 tables cannot be altered, so the index has to be rebuilt.

You added a key to searchableFilters(), or turned on scout.soft_delete, after the table was created. FTS5 has no ALTER TABLE, so the column cannot be added in place.

php artisan scout:fts5-rebuild "App\Models\Customer"

The driver checks this on every write rather than letting the insert fail with SQLite’s own no such column, which says nothing about what to do next.

Cannot filter search results by […]

It is not an indexed filter column of […]. Available columns: …

A where() names a column that is neither in the index nor on the model’s table. Usually a typo — the message lists everything you could have meant.

If the column really should be filterable and lives elsewhere, add it to toSearchableArray() as a filter and rebuild. See filtering.

Cannot order or filter this search by a column of […]

Its table lives on connection […] while the index is on […], and SQLite cannot join across connections.

scout-fts5.connection points at a different database from the models. That is a supported arrangement — see keeping the index in its own file — but it limits searches to indexed filter columns and relevance ordering.

Either move the index onto the model’s connection, or declare the column in searchableFilters() and rebuild. An explicit orderBy() cannot be rescued that way; relevance ordering is the only option across connections.

The scout-sqlite-fts5 driver requires an SQLite connection

Point scout-fts5.connection at an SQLite connection.

scout-fts5.connection resolves to a MySQL, Postgres or SQL Server connection. This driver is SQLite-only; it checks at boot rather than failing on the first CREATE VIRTUAL TABLE.

Leave the option null to use the default connection, or name your SQLite one. If your models genuinely do not live in SQLite, use namoshek/laravel-scout-database instead.

Errors SQLite throws

no such module: fts5

Your SQLite build has FTS5 compiled out. Check with:

php -r 'echo (new PDO("sqlite::memory:"))->exec("CREATE VIRTUAL TABLE t USING fts5(c)") === false ? "missing" : "ok";'

It is enabled by default in every mainstream build since SQLite 3.9, so a missing module usually means a hand-compiled libsqlite3 or an unusual Docker base image. On Alpine, install sqlite-libs; on a custom build, add -DSQLITE_ENABLE_FTS5.

database is locked

Not specific to this package — it is SQLite’s writer lock, and indexing is a write. If it started when you enabled the driver, the fix is usually WAL mode:

// config/database.php
'sqlite' => [
    // …
    'journal_mode' => 'wal',
],

Moving indexing off the request also helps: set scout.queue to true.

Behaviour that looks like a bug

Search returns nothing after I changed a setting

Changing tokenizer, normalizer or suffix changes how content was stored, but not the content already stored. The index and the query stop agreeing.

php artisan scout:fts5-rebuild

Nonsense queries return results

The substring pass is matching on too little. Raise trigram.min_ratio toward 0.6, or turn the pass off entirely:

'trigram' => ['min_ratio' => 0.6],
// or
'passes' => ['trigram' => false],

Check what is actually happening first — Customer::search($term)->raw()->pass() names the pass that answered.

A misspelling that used to work stopped working

The mirror image of the above: min_ratio too high, or typo.trim too low for the ending in question. See the typo pass.

Results are not in relevance order

An explicit orderBy() — including latest() and oldest() — replaces relevance ordering. That is Scout’s contract, not a quirk of this driver. Drop the ordering to get BM25 order back.

Note that Livewire components often add ->latest() out of habit; on a search query it overrides the ranking you are paying for.

Search is slow

In rough order of likelihood:

  1. A query that finds nothing runs all four passes, ending in a scan. Turn off trigram if that is the common case on a large table.
  2. The index has many small segments after a bulk import. Run php artisan scout:fts5-optimize.
  3. A filter on an undeclared column forces a join on every search. Move it into searchableFilters() and rebuild.
  4. String-keyed models scan on every write. See models with string keys.

Indexing is slow during an import

Import into a fresh index rather than over an existing one, so each row has nothing to delete first:

php artisan scout:fts5-rebuild "App\Models\Customer"

More on bulk imports →

Working out what happened

raw() returns the driver’s own result object, which knows more than the model collection does:

$result = Customer::search($term)->raw();

$result->pass();   // which pass answered: prefix, typo, any, trigram, or null
$result->total();  // how many documents matched, ignoring pagination
$result->ids();    // the keys, in the order the ranking put them

To see the SQL the driver actually runs:

DB::listen(fn ($query) => logger($query->sql, $query->bindings));

Architecture

← back to the README

The package is small, but it sits between three things that each have opinions — Eloquent, Scout and FTS5 — so it helps to see where the boundaries fall. The diagrams below follow the C4 model, zooming in one level at a time.

Level 1 — System context

C4Context
    title Searching in an application that uses this driver

    Person(user, "Application user", "Types a query into a search box")
    Person(dev, "Developer", "Declares what is searchable")

    System(app, "Laravel application", "Calls Model::search() and never touches the index directly")
    SystemDb(db, "SQLite database file", "Application tables and their full-text index, in one file")

    Rel(user, app, "Searches")
    Rel(dev, app, "toSearchableArray(), searchableFilters()")
    Rel(app, db, "Queries and indexes", "PDO")

The point of the whole package is what is missing from this picture: there is no search service. With Meilisearch, Typesense or Algolia there would be a second system here, with its own process, network hop, credentials and failure modes. Here the index is a set of tables in the database file the application already opens.

Level 2 — Containers

C4Container
    title Inside the Laravel application

    Person(user, "Application user")

    Container_Boundary(app, "Laravel application") {
        Container(models, "Eloquent models", "PHP", "Use the Searchable trait and declare what to index")
        Container(scout, "Laravel Scout", "PHP", "Resolves the engine and observes model events")
        Container(driver, "scout-sqlite-fts5", "PHP", "Turns Scout's calls into SQL")
    }

    Container_Boundary(file, "One SQLite database file — your app's usual connection") {
        ContainerDb(tables, "Model tables", "SQLite", "customers, orders, invoices")
        ContainerDb(fts, "FTS5 index tables", "SQLite", "customers_fts, orders_fts, invoices_fts")
    }

    Rel(user, models, "Model::search()")
    Rel(models, scout, "Saves and deletes raise events")
    Rel(scout, driver, "update(), delete(), search(), paginate()")
    Rel(driver, fts, "MATCH, bm25(), INSERT, DELETE")
    Rel(driver, tables, "Joins for ordering and undeclared filters")
    Rel(scout, tables, "Hydrates the models that matched")

The two stores in that diagram are one database. There is no second file, no second connection and nothing to provision: the index tables are created next to your own, on the connection Laravel is already configured with, and they are backed up, replicated and opened along with everything else. They are drawn apart only because one holds your data and the other holds the index over it.

Two arrows carry most of the design.

The first is driver → tables. The index is not a separate world: by default it lives in the same file as the data, which is why the driver can join the model’s own table to answer a filter on a column that was never indexed, or to sort by one. Move the index to its own connection and that arrow disappears, taking those two abilities with it. An engine talking to a remote service cannot do that — it would have to either refuse the query or fetch everything and sort in PHP.

The second is scout → tables. The driver returns keys, not models. Hydration is Scout’s job, and it applies whatever constraints the caller attached with query().

Level 3 — Components

C4Component
    title Inside the driver

    Container(scout, "Laravel Scout", "PHP", "Calls the engine")

    Component(engine, "Engine", "Scout Engine", "The entry point Scout knows about; delegates and preserves result order")
    Component(indexer, "Indexer", "Write path", "Flattens searchable data and writes documents")
    Component(seeker, "Seeker", "Read path", "Runs the cascade, filters, orders, paginates")
    Component(schema, "Support\\Schema", "DDL", "Names, creates and inspects virtual tables")
    Component(pass, "Support\\SearchPass", "Strategy", "One attempt: how to constrain, how to rank")
    Component(query, "Support\\MatchQuery", "Escaping", "Quotes words as FTS5 phrases")
    Component(tokens, "Support\\Tokens", "Text", "Splits words, shortens them, builds substrings")
    Component(norm, "Normalizer", "Contract", "Folds text the same way on both sides of the index")
    Component(config, "SearchConfiguration", "Settings", "Typed view over the config array")

    ContainerDb(fts, "FTS5 virtual tables", "SQLite")

    Rel(scout, engine, "search(), update(), delete()")
    Rel(engine, indexer, "Writes")
    Rel(engine, seeker, "Reads")
    Rel(indexer, norm, "Normalizes content")
    Rel(indexer, schema, "Creates tables on demand")
    Rel(seeker, tokens, "Splits the query")
    Rel(seeker, pass, "Builds the cascade")
    Rel(pass, query, "Escapes words")
    Rel(seeker, config, "Reads tuning")
    Rel(indexer, fts, "INSERT, DELETE")
    Rel(seeker, fts, "SELECT … MATCH")

The split that matters is Indexer and Seeker: the write path and the read path share nothing but the Schema that names their tables and the Normalizer that has to fold text identically on both sides. Everything under Support is free of framework imports and could be tested without booting an application.

A search, end to end

sequenceDiagram
    autonumber
    participant App as Application
    participant Scout as Laravel Scout
    participant Seeker
    participant SQLite

    App->>Scout: Customer::search('kowalsky')->paginate(20)
    Scout->>Seeker: paginate(builder, 20, 1)
    Seeker->>Seeker: normalize, split into words

    rect rgb(240, 240, 240)
        note over Seeker,SQLite: pass 1 — every word as a prefix
        Seeker->>SQLite: COUNT … MATCH '"kowalsky"*'
        SQLite-->>Seeker: 0
    end

    rect rgb(240, 240, 240)
        note over Seeker,SQLite: pass 2 — shortened prefix
        Seeker->>SQLite: COUNT … MATCH '"kowals"*'
        SQLite-->>Seeker: 7
        Seeker->>SQLite: SELECT rowid … ORDER BY bm25() LIMIT 20
        SQLite-->>Seeker: 7 keys, best match first
    end

    Seeker-->>Scout: SearchResult(keys, total: 7, pass: 'typo')
    Scout->>SQLite: SELECT * FROM customers WHERE id IN (…)
    SQLite-->>Scout: models
    Scout-->>App: LengthAwarePaginator, in relevance order

Passes three and four never run: the cascade stops at the first one that matches. A query that finds an exact hit costs one COUNT and one SELECT; only a query that finds nothing pays for every interpretation, ending in the substring scan.

The count is a separate statement from the page. That is what lets the paginator report seven matches while returning at most twenty rows — and what keeps ordering and slicing in SQL rather than in PHP.

Decisions

← back to the README

Why this package works the way it does. Each record states the problem as it stood, what was chosen, and what that choice costs — the last part being the one worth reading before you file a bug about it.

Record 0 is the one the package exists to serve; the rest are choices made underneath it.

#DecisionCosts you
0Why this exists: cheap, simple indexing for Scout on SQLiteNarrow scope by design
1Build on SQLite FTS5 rather than a portable indexRuns on SQLite only
2Derive index tables from models, not migrationsSchema changes need a rebuild
3Store integer keys in rowidString-keyed models scan on write
4Answer a query with a cascade of passesA query that finds nothing runs all four
5Require a share of one word’s substringsA threshold to tune, not a universal answer
6Order, filter and paginate in SQLThe index must share a connection with the models
7Quote every word as an FTS5 phraseUsers cannot write FTS5 query syntax
8Throw on a filter that matches no columnA typo breaks the request instead of quietly narrowing it
9Test through a booted Laravel applicationThe full framework is a dev dependency

0. Cheap, simple indexing for Scout on SQLite

Status: accepted

This is the decision the package exists to serve. Every record that follows is a choice made underneath it.

Context

Laravel has defaulted to SQLite since version 11, and a growing share of applications never leave it: desktop builds, NativePHP apps, single-tenant deployments, internal tools, CLI utilities, small SaaS products. Plenty of them are in production, with real users typing into a real search box.

When one of them needs search, the options are poor at both ends.

Scout’s built-in database driver needs no infrastructure of its own: it searches the model’s columns with LIKE, or through a full-text index you add and maintain yourself.

The engines that do index properly — Meilisearch, Typesense, Algolia — mean adopting a second datastore: a process to run, a schema to keep in sync, credentials to manage, a network hop in the request, a new failure mode, and for the hosted ones a bill. For an application whose entire database is one file on disk, that is a large amount of machinery for a search box.

Nothing occupies the middle, and the middle is where most of these applications live. The gap is not that good search is unavailable — it is that the cheapest good search available costs far more to adopt and operate than the application it is going into.

Decision

Fill that middle: give Scout real indexed full-text search on SQLite, and treat cost of adoption and operation as the property to optimise, ahead of portability and ahead of feature breadth.

Concretely, “cheap and simple” was taken to mean:

  • Nothing new to run. No daemon, no container, no API key, no network hop.
  • Nothing new to maintain. No migration to publish and hand-edit, no index schema in version control — the models already declare what is searchable, so derive it from them.
  • Nothing new to learn. The public surface is Model::search(). Everything is the Scout API an application already uses.
  • Nothing to tune before it works. The defaults are meant to be usable as they are, including the typo tolerance.

Consequences

The package is deliberately narrow. It is not competing with Meilisearch on features — there is no geo search, no synonyms, no facets, no distributed anything — and an application that outgrows it should move to a real search engine rather than expect this to grow into one. Swapping back out is a one-line change of SCOUT_DRIVER, which is the point of building on Scout.

Optimising for cheapness fixed the technology: SQLite already ships an inverted index, and using it beats hand-rolling one, at the price of running nowhere else. That trade is decision 1.

Optimising for simplicity fixed the workflow: index tables come from models rather than migrations, which is decision 2, and missing tables can be created on first write.

Optimising for “works without tuning” is why search is a cascade rather than one query with knobs — decision 4 — and why the substring pass carries a threshold that a user should never have to think about, in decision 5.

The remaining records are consequences of those three.

1. Build on SQLite FTS5 rather than a portable index

Status: accepted

Context

A Scout driver that stores its index in the application’s own database can go two ways.

It can build an inverted index by hand, in ordinary tables — one row per term per document — and run on every database Laravel supports. This is what namoshek/laravel-scout-database does, and it is the right answer if your models live in MySQL or Postgres.

Or it can use a full-text engine the database already ships. SQLite has one: FTS5, a real inverted index with BM25 ranking, prefix queries and its own tokenizers, maintained by the database rather than by us.

Decision

Use FTS5, and accept that the package works only on SQLite.

Consequences

The index is maintained by SQLite. There is no term table to keep consistent, no scoring to implement in SQL, and no query planner to fight — MATCH and bm25() are the engine’s own.

Ranking comes for free and is better than anything worth hand-rolling: BM25 weighs term rarity and document length, which a naive implementation does not.

The package refuses to boot on a connection that is not SQLite, rather than failing halfway through a CREATE VIRTUAL TABLE. Anyone on another database is pointed at the portable alternative in the error message and the README.

Applications that ship a database file — desktop builds, NativePHP, single-tenant deployments, CLI tools — get full-text search with no service to run, which is the audience this package is for.

2. Derive index tables from models, not migrations

Status: accepted

Context

The implementation this package grew out of created its FTS5 tables in a migration with the table names written out by hand:

foreach (['customers', 'orders', 'invoices', 'products', 'tire_storages'] as $table) {
    DB::statement("CREATE VIRTUAL TABLE {$table}_fts USING fts5(...)");
}

That works in the application it was written for and nowhere else. A published package cannot ship a list of somebody else’s tables, and a migration the user has to hand-edit after every vendor:publish is a poor substitute.

Decision

Take the table name from searchableAs(), the filter columns from searchableFilters(), and create tables from the models themselves — through scout:fts5-create, or on demand at the first write when auto_create is on.

Consequences

There is no migration to publish and nothing about the index in version control. The model is the single declaration of what is searchable.

Because FTS5 has no ALTER TABLE, a table whose columns no longer match its model cannot be migrated in place. The driver detects the mismatch on write and raises an error naming the rebuild command, rather than letting the insert fail on SQLite’s no such column.

scout:fts5-rebuild is therefore a normal part of the workflow rather than a recovery tool, and the commands discover models by scanning the configured paths so none of this needs a list maintained by hand.

Engine::createIndex() receives only a name from Scout’s own scout:index, with no model behind it, so it creates a table with no filter columns. Models that declare filters must be created through this package’s commands, which can see the model.

3. Store integer keys in rowid

Status: accepted

Context

An FTS5 table has no indexes of its own. Every column is either tokenized into the full-text index or UNINDEXED, and neither can be looked up the way a b-tree column can. Storing the document key in an ordinary column means every update and delete scans the table.

It does have a rowid, though, and a rowid lookup is a b-tree lookup. It only accepts integers.

Decision

Store the key in rowid when the model’s key is an integer. Fall back to an explicit doc_id UNINDEXED column when it is a string.

Consequences

The common case is fast. Reindexing one model after a save is DELETE FROM t WHERE rowid = ? followed by an insert, both logarithmic.

UUID and ULID models still work, but pay a scan per write. It is invisible at small scale and matters during a bulk import, which is why the documentation points those users at scout:fts5-rebuild — importing into a table with nothing in it has nothing to delete.

The two layouts are decided per model from getKeyType(), so nothing about the choice needs to be stored or configured. A model that overrides getScoutKey() to return a string while declaring an integer key type would break this; that combination is not supported.

4. Answer a query with a cascade of passes

Status: accepted

Context

Users misspell things, and a search box that only finds exact prefixes feels broken. The usual answers are a fuzzy engine that scores edit distance, or one clever query that is simultaneously strict and forgiving.

The second does not exist. A query loose enough to find kowalsky for Kowalski is loose enough to find twenty other people too, and it applies that looseness to the queries that were spelled correctly in the first place.

Decision

Try several queries in sequence, from strictest to loosest, and stop at the first that matches anything: every word as a prefix, then shortened prefixes, then any word, then substrings.

Consequences

Precision is preserved where it exists. A query that matches exactly is answered by the strict pass and never sees the fuzzy ones — the looseness only applies when strictness found nothing.

Cost follows the same shape. An exact query is one indexed MATCH; only a query that finds nothing pays for all four, and the expensive scan is last.

The result reports which pass answered, so an application can tell the user it guessed rather than silently returning something they did not ask for.

Passes that would repeat an earlier query are skipped: words too short to shorten make the second pass identical to the first, and a single-word query makes “any word” identical to “every word”.

The cost is four round trips in the worst case, and four behaviours to understand instead of one. Each pass can be turned off in configuration for applications that would rather return nothing than guess.

5. Require a share of one word’s substrings

Status: accepted

Context

The last pass exists to catch typos in the middle of a word, where no prefix query can reach — kowerlski shares no usable prefix with kowalski. Comparing three-character substrings finds it.

The naive form of that pass — match if the content contains any of the query’s substrings — matches nearly everything. zupelnie inne slowa has nothing to do with Jan Kowalski, but kowalski contains owa, and one shared substring was enough. This was found by a test asserting that a nonsense query returns nothing, and it did not.

A threshold across the whole query does not separate the two either. The real typo shares 3 of 7 substrings (43%); the nonsense query shares 1 of 12 (8%) — but that 1 belongs to a single word, and averaging it across the query hides which word it came from.

Decision

Group substrings by the word they came from, and match a document when it contains enough of a single word’s substrings — 40% by default.

Consequences

The two cases separate cleanly. A misspelled word keeps most of its own substrings; an unrelated query shares a stray one or two of any single word’s.

QueryBest wordResult
kowerlski3 of 7 (43%)matches
zupelnie inne slowa1 of 3 (33%)no match

The margin is not enormous, and the threshold is exposed as trigram.min_ratio because the right value depends on the language and the length of the indexed fields.

Words shorter than the substring size are skipped rather than matched whole, since a two-character substring matches almost anything. This is why a two-character query that matches no token returns nothing — and why CJK, whose words are often two characters, needs trigram.size lowered to be reachable this way.

6. Order, filter and paginate in SQL

Status: accepted

Context

A Scout engine returns keys; Scout hydrates them into models. The tempting shortcut is to fetch every matching key, slice the page in PHP, and let the caller’s orderBy() sort the models that came back.

That shortcut is wrong in a way that is easy to miss in testing. Sorting the current page sorts one arbitrary slice of the results — so a record can appear on two pages, or on none, and the pages do not add up to the result set. It only shows up once someone clicks through to page two with an ordering applied.

Filters have a related problem. Scout’s where() can only be answered by the index, so an engine either supports filtering on indexed columns and ignores the rest, or refuses.

Decision

Push ordering, filtering and pagination into the query against the index. Answer filters on declared columns from the index table, and join the model’s own table for anything else — including every explicit ordering.

Consequences

Pages are consistent with the order that produced them, and the reported total is every document that matched rather than the size of the current page.

Filters on columns that were never indexed work, because the index is in the same SQLite file as the data and the join is local. Nothing is silently dropped.

This is what ties the package to a single connection: the index and the models must be in the same database for the join to be possible. That constraint is checked at boot and stated in the documentation.

Relevance order survives hydration — the engine sorts the models back into the order the ranking produced, rather than letting whereIn return them in whatever order the database prefers.

An explicit orderBy() replaces relevance ordering rather than supplementing it, which is Scout’s contract. Applications that add latest() out of habit lose the ranking they are paying for.

7. Quote every word as an FTS5 phrase

Status: accepted

Context

The contents of a MATCH expression are not a string to be searched for. FTS5 parses them as a query language with its own operators — AND, OR, NOT, NEAR, parentheses, quotes, a - prefix and a * suffix.

Passing a user’s input straight through means they are writing queries rather than searching. Most of the time that produces surprises: kowalski OR nowak returns everyone named Nowak, and NEAR(jan) returns a syntax error rather than results. Binding the expression as a statement parameter prevents SQL injection but does nothing about this — the parameter’s contents are still parsed as FTS5.

Decision

Emit each word as a quoted FTS5 phrase, doubling any quote inside it, and append the prefix star outside the quotes:

"jan"* AND "kowalsky"*

Consequences

Query syntax the user typed is searched for literally. kowalski OR nowak looks for the word or; a stray " is a character. The test that pins this asserts the query falls through to the any pass — proof that the strict pass did not interpret OR as an operator.

Users cannot write FTS5 queries. Nobody can type title:foo or NEAR(a b, 5) and have it work. For a search box that is the right trade; an application that wants an advanced syntax has to build it on top and construct its own expressions.

The escaping lives in one class, Support\MatchQuery, so there is a single place where the boundary between user text and query language is crossed.

8. Throw on a filter that matches no column

Status: accepted

Context

Customer::search($term)->where('statuss', 'active') names a column that does not exist. An engine can ignore the constraint, or refuse the query.

Ignoring is the friendlier-looking option and the more dangerous one. A filter is usually there to remove results — a tenant scope, an archived flag, a visibility check. Dropping it silently returns more than the caller asked for, which in the multi-tenant case means returning another tenant’s records.

Decision

Throw when a filter names a column that is neither an indexed filter nor a column on the model’s table. Name the available columns in the message.

Consequences

A typo breaks the request loudly, at the first search, instead of quietly widening the result set. The message lists what the field could have been, which turns most occurrences into a one-second fix.

This is deliberately asymmetric with the missing-index case, which returns no results rather than throwing. The reasoning is which way each failure is safe: a filter that vanishes shows too much, so it must fail; a missing index shows nothing, and breaking every page in the application on a dropped table is worse than an empty result.

Applications that pass user-supplied field names into where() must validate them first — though a search that filters on arbitrary user-named columns has a larger problem than this exception.

9. Test through a booted Laravel application

Status: accepted

Context

Most of this package only exists in the presence of a framework. Model::search() goes through Scout’s EngineManager, indexing is triggered by Eloquent model events, configuration comes from the config repository, and four of the moving parts are artisan commands.

Testing that without a Laravel application means either building a container, a config repository and an Eloquent bootstrap by hand — more code than the tests, and drifting from real behaviour as the framework changes — or testing only the parts that need no framework and leaving the interesting behaviour uncovered.

Decision

Use orchestra/testbench, which boots a minimal Laravel application in-process, and test through the same surface an application uses: create a model, search for it.

Consequences

The tests exercise the real path — the service provider registers the driver, saving a model triggers indexing through Scout’s observer, $this->artisan() runs the commands through a real kernel.

The Testbench version pins the framework version, which is what makes the CI matrix possible: ^10.0 tests against Laravel 12 and ^11.0 against Laravel 13, from the same test suite.

The whole framework becomes a development dependency. It does not ship — .gitattributes keeps tests/ out of the distribution, verified by installing the published package and listing what arrived.

Four classes — Tokens, MatchQuery, DiacriticsNormalizer and SearchConfiguration — have no framework imports and are currently covered only indirectly, through a booted application and a real database. Testing them directly would be faster and more precise, and is worth doing.