teamtnt/laravel-scout-tntsearch-driver
Full-text search for Laravel models via Scout and TNTSearch, supporting Laravel 5.3 through 13. Installs with Composer, registers Scout and TNTSearch service providers, sets SCOUT_DRIVER=tntsearch, and publishes the scout config.
1. Install both layers. Scout is the abstraction; this package is the engine.
composer require laravel/scout teamtnt/laravel-scout-tntsearch-driver
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
2. Register providers. Laravel 11+ in bootstrap/providers.php; older versions in config/app.php. Order matters — Scout first:
return [
App\Providers\AppServiceProvider::class,
Laravel\Scout\ScoutServiceProvider::class,
TeamTNT\Scout\TNTSearchScoutServiceProvider::class,
];
3. Point Scout at the driver.
SCOUT_DRIVER=tntsearch
TNTSEARCH_FUZZINESS=false
TNTSEARCH_MAX_DOCS=500
4. Add the tntsearch block to config/scout.php. The only mandatory-ish key is storage; everything else has sane defaults but you should set them deliberately:
'tntsearch' => [
'storage' => storage_path(),
'fuzziness' => env('TNTSEARCH_FUZZINESS', false),
'fuzzy' => [
'prefix_length' => 2,
'max_expansions' => 50,
'distance' => 2,
'no_limit' => true,
],
'asYouType' => false,
'searchBoolean' => env('TNTSEARCH_BOOLEAN', false),
'maxDocs' => env('TNTSEARCH_MAX_DOCS', 500),
'stopwords' => [],
],
Also add /storage/*.index to .gitignore — the index is a SQLite file, not source code, and must never be deployed from a repo.
Where to look first
config/scout.php → the tntsearch array is the whole API surface of this package.storage/*.index → one SQLite file per searchable model. If a model has no .index file, search silently returns nothing.php artisan scout:status (optionally scout:status "App\Models\Post") → the fastest way to confirm an index exists, how many documents it holds, and whether it's stale.First use case — make one model searchable and prove it works end to end:
use Laravel\Scout\Searchable;
class Post extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => strip_tags($this->body),
];
}
}
php artisan scout:import "App\Models\Post"
Post::search('Bugs Bunny')->get();
If get() returns an empty collection, check three things in order: the .index file exists in storage/, SCOUT_DRIVER=tntsearch is actually set in the environment the app booted with (php artisan tinker will tell you), and the words you searched are present in toSearchableArray().
toSearchableArray() instead of dumping toArray()The README's $array = $this->toArray() is a starting point, not a recommendation. The index is flat text — every column you ship becomes searchable noise (IDs, timestamps, enum flags, JSON blobs). Denormalize deliberately:
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => strip_tags($this->body),
'author' => $this->author->name, // joined in, searchable as text
'category' => $this->category->name,
];
}
This is also how you make relationships searchable — TNTSearch has no concept of joins, so relation data must be flattened at index time. Note the corollary below about tntsearch:import.
constrain() lastThis is the pattern you'll use in almost every controller. Scout searches the index for IDs, then narrows via your Eloquent query — so constraints must be attached after every where() is applied:
$query = Post::query();
if (!auth()->user()->hasRole('admin')) {
$query->where('internal_post', false);
}
if ($request->topic) {
$query->whereNotIn('id', fn ($q) => $q->select('assigned_to')
->from('comments')->where('topic', $request->topic));
}
$query->where('deleted', false);
$results = $request->searchTerm
? Post::search($request->searchTerm)
->constrain($query)
->orderBy('updated_at', 'asc')
->paginate(10)
: $query->orderBy('updated_at', 'asc')->paginate(10);
orderBy() on the search builder behaves the same way — apply it after constrain(). Constraints are Eloquent-level, so scopes, byRole()-style local scopes, and subqueries all work unchanged.
| Command | Speed | Uses toSearchableArray() |
Relations |
|---|---|---|---|
scout:import |
slower | yes | yes |
tntsearch:import |
fast | no (reads DB columns directly) | no |
Use scout:import when toSearchableArray() contains relation data or computed values. Use tntsearch:import for large, flat tables — it chunks and streams straight from the database, which is usually the difference between a two-minute and a forty-minute first import on a big table.
Model saves, updates, and deletes keep the index current automatically via the Searchable trait — no cron needed. For bulk operations that bypass events (DB::table(), raw SQL, Model::query()->update()), re-index explicitly:
$post->searchable();
Post::where('year', '>', 2018)->get()->searchable();
Better still, put those bulk writes behind queued indexing (Scout's SCOUT_QUEUE=true) so imports and user-facing saves don't block on a file write.
asYouTypeFor an autocomplete input where the last token is still being typed, enable prefix matching — globally via config/scout.php, or per model:
class Post extends Model
{
use Searchable;
public $asYouType = true;
}
All preceding words still require exact matches; only the final word becomes a LIKE 'word%' lookup.
With searchBoolean => true, queries are parsed as expressions rather than ranked free text:
php -laravel → contains php, excludes laravelcat or dog → either termTradeoff: you lose relevance ranking. Good for an "advanced search" form behind a checkbox; bad for the default search box your users type natural language into.
Fuzzy matching is the fallback layer: with no_limit => false it only fires when exact search finds nothing; with no_limit => true fuzzy candidates are added to every result set (more recall, more noise, slower). Tune distance (edit distance, default 2), max_expansions (candidate terms compared — 50 default is a good speed/recall balance), and prefix_length (leading chars that must match exactly; raising it is the cheapest performance win on large indexes).
maxDocs silently truncates your results. It's a per-term cap (default 500, ordered by hit_count), not a result limit. A boolean AND on a very common term will miss legitimate matches on large indexes. If boolean queries return fewer results than expected, raise TNTSEARCH_MAX_DOCS before suspecting anything else.
Stopwords are applied at index time. Change stopwords and you must reimport every affected model — no live effect. They're also the fix for a specific fuzzy-match annoyance documented upstream: a search for inns won't fuzzy-match documents containing merely in once in is a stopword.
The index is a local file, not a service. That means no horizontal scaling out of the box: every app server needs its own copy, or you need shared local storage mounted at the storage path. storage must be a local filesystem path — S3 or similar will not work. Concurrent writes from multiple queue workers to the same .index is the classic source of corruption and stale reads; keep indexing on a single worker or serialize it.
Index files are not migrated. Adding a column to toSearchableArray() does nothing until you reimport. Treat index schema changes as deploy steps: scout:flush then scout:import (or a fresh tntsearch:import).
Search results ignore database ordering by default. Post::search(...)->get() returns models ordered by TNTSearch relevance. If you need stable ordering for pagination, add ->orderBy() explicitly — and be aware that mixing relevance with orderBy is often confusing to users; usually pick one.
Deletes and soft deletes. Hard deletes remove documents from the index automatically. If you use SoftDeletes, verify the document actually disappears from results after a soft delete — otherwise you'll return "deleted" content through search. The safe pattern is to constrain soft-deletable models explicitly with whereNull('deleted_at') on the constrain query, or use makeAllSearchableUsing()/toSearchableArray() logic to exclude them.
scout:status finds nothing? The command scans app/ by default. Models elsewhere (e.g. app/Domain/... outside the expected root) need 'modelPath' => 'models' in the tntsearch config block.
Read replicas work. Since v16.1.0 the driver uses getReadPdo() throughout, so imports can run from a read-only connection — index writes go to the file, not the database. Don't configure a read connection that's lagging for a search-constrain query, or you'll get ID mismatches between index and results.
Testing. Swap SCOUT_DRIVER=null (or collection) in phpunit.xml. Asserting against real .index files in CI is slow and flaky; assert on the engine boundary instead, and keep a small integration test that imports a fixture table and hits search() once.
Laravel 13 upgrade note. v16.0.0 fixed an infinite recursion bug in the engine's extend closure caused by Laravel 13's Manager::extend rebinding $this. If you subclass the engine (Scout::extend('tntsearch', ...)) on Laravel 13, stay on v16.0.0+ — earlier versions loop until the request dies.
Extension points, in order of cost:
toSearchableArray() — 90% of customization lives here.$asYouType) for per-model behavior overrides.config/scout.php → tntsearch for global engine tuning.Scout::extend('tntsearch', fn () => new MyEngine(...)) to wrap or replace TNTSearchEngine when you need custom tokenization or post-processing of hits — check the engine's public API before inheriting, and note it must satisfy Scout's Engine contract.Debugging checklist when search returns nothing or the wrong thing:
php artisan scout:status
php artisan scout:flush "App\Models\Post" && php artisan scout:import "App\Models\Post"
Then confirm SCOUT_DRIVER, confirm the .index file's mtime is recent, and confirm the search term survives toSearchableArray() — a strip_tags() or truncation rule that drops the word you're testing is the most common false alarm.
How can I help you explore Laravel packages today?