baril/sqlout
A Laravel debugging package that captures and outputs the SQL queries your application executes, making it easy to inspect database activity, trace query flow, and troubleshoot issues during development.
1. Install and register the driver
composer require baril/sqlout
Publish the package config if one is provided, then point Scout at it:
php artisan vendor:publish --tag=sqlout-config # if the tag exists
// config/scout.php
'driver' => 'sqlout',
Clear config cache (php artisan config:clear) so the driver name resolves — a stale scout.driver is the #1 reason search() silently returns nothing or throws "driver not supported".
2. Confirm the index-per-table mapping (new in v5.2.0)
From 5.2.0 the package supports indexing models that live in different tables. Open config/sqlout.php first and check how table/index targets are declared, then add the matching FULLTEXT indexes yourself — the package still does not create them:
Schema::table('posts', function (Blueprint $table) {
$table->fullText(['title', 'body']); // one composite index
});
Then import existing rows:
php artisan scout:import "App\Models\Post"
3. Make the model searchable
use Laravel\Scout\Searchable;
class Post extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'title' => $this->title,
'body' => $this->body,
];
}
}
The keys returned by toSearchableArray() must line up with the columns in that model's FULLTEXT index — the package maps the trait array onto the MATCH (...) column list. With multi-table support, each model's array must match its own index, not a global one.
Where to look first
SqlOutEngine) — it is small and readable; read it before debugging anything, and re-read it after 5.2.0 since builder passthrough changed.config/scout.php in your app, to confirm the driver name and queue settings.First use case
Post::search('eloquent relationships')->get();
You get back hydrated Eloquent models, ordered by MySQL relevance, with no Algolia/Meilisearch/Elasticsearch running anywhere. That is the entire value proposition: search with zero external infrastructure on an existing MySQL box.
Filter first, search second
Scout passes where() clauses through to the underlying Eloquent builder, so constraints compose normally:
Post::search('migration')
->where('status', 'published')
->where('tenant_id', $tenantId)
->paginate(20);
As of 5.2.0, whereIn() and whereNotIn() are actually forwarded too. Before 5.2.0 they were silently dropped — see the gotchas. Keep filters before paginate()/get() so LIMIT is applied after filtering.
Use query() for custom constraints (not scope())
5.2.0 deprecates the package's scope() method in favour of Scout's query():
Post::search($q)->query(fn ($builder) => $builder->whereIn('tag_id', $tagIds))->get();
scope() still works for now, but migrate — it is the documented seam and will be removed.
Boolean-mode operators for predictable matching
Reach for MySQL boolean syntax when users supply structured input:
Post::search('+"database migration" -sqlite eloquent*')->get();
+term required, -term excluded, "exact phrase" phrase match, term* trailing wildcard (prefix only — leading * is not supported by MySQL).Pagination and lightweight result sets
paginate() works because the engine returns a normal Eloquent builder. If you only need IDs (e.g. for a second-pass query, or a large result set), select narrowly rather than hydrating models:
$ids = Post::search($q)->take(500)->keys();
Eager loading still applies
Post::search($q)->with('author:id,name')->paginate(20);
The fulltext clause doesn't interfere with with(), so list views stay N+1-free.
Sync strategy
Use Scout's model events (saved/deleted triggers re-index) for correctness, and scout:import for backfills. If you run the import on a live table, batch it (--chunk=500) and run it off-peak: every update rewrites InnoDB fulltext index entries and the initial build is the expensive part.
Multi-table indexing (new in 5.2.0)
You can now search models spread across different tables — a posts index, a documents index, a tickets index — each backed by its own FULLTEXT index. Practical pattern:
// config/sqlout.php — one entry per searchable model, pointing at its own table + columns
'indexes' => [
App\Models\Post::class => ['table' => 'posts', 'columns' => ['title', 'body']],
App\Models\Document::class => ['table' => 'documents', 'columns' => ['name', 'content']],
],
If you need unified search, fan out and merge in PHP rather than trying to build one cross-table index:
$results = collect()
->merge(Post::search($q)->take(10)->get())
->merge(Document::search($q)->take(10)->get());
Relevance scores are not comparable across tables — sort by a business key (recency, type weight), not by score.
Multi-tenancy
Because relevance is computed per query, the cleanest tenant pattern is a composite index over (tenant_id, title, body) — but MySQL FULLTEXT matches across the whole column list, so tenant_id would be tokenized too. In practice: index only the text columns, and always constrain tenant_id in a where()/whereIn() chain. Never rely on the search alone to scope data.
Compose with Eloquent scopes
public function scopePublished($q) { return $q->whereNotNull('published_at'); }
Post::search($q)->published()->get();
Note the tension: latest() will override relevance ordering. See the gotchas below.
Fallback for when you outgrow it, without ripping it out
Laravel 9+ ships whereFullText() on the query builder for MySQL/PostgreSQL. You can call it directly for one-off endpoints, which means a later migration off this package is mostly a find-and-replace of Model::search() with a whereFullText()->orderByRaw(...) scope. Treat the package as a thin convenience over a native capability, not a lock-in.
MySQL only, and the engine is not portable
SQLite (the default test database) and PostgreSQL do not understand MATCH ... AGAINST. If your test suite uses :memory: SQLite, every search test fails. Options: run tests against a MySQL connection, or bind a fake engine implementing Laravel\Scout\Engines\Engine in the container. Do not discover this in CI after a refactor.
whereIn() silently dropped before 5.2.0 — an authorization risk
This is the most important reason to bump to ^5.2. On older versions, whereIn() and whereNotIn() on the search builder were ignored: the constraint never reached SQL. A tenant filter written as ->whereIn('tenant_id', $allowedIds) returned everyone's rows. If you are pinned below 5.2.0, audit every search call that relies on whereIn, and prefer plain where() until you upgrade. Add a regression test asserting the generated SQL contains the IN (...) clause.
No index, no query — and the error is not obvious
MATCH (title, body) AGAINST (...) throws Can't find FULLTEXT index matching the column list if the index doesn't exist or if the column list ordering/contents differ even slightly from the index definition. If you add a column to toSearchableArray() without a matching migration, search breaks immediately. Keep the array and the migration in one commit — and with multi-table support, keep them paired per model.
Short words and stopwords silently vanish
InnoDB defaults innodb_ft_min_token_size to 3 and maintains a stopword list. Searching "go", "a/b", or common English words returns nothing, with no error. Symptom: user reports "search is broken" but only for certain terms. Check with:
SHOW VARIABLES LIKE 'innodb_ft_min_token_size';
SELECT * FROM information_schema.INNODB_FT_DEFAULT_STOPWORD;
Custom stopword files only fix half the problem
5.2.0 lets you load stopwords from a .txt or a PHP file in the package config — useful for adding domain terms (the, inc, your product names) so they don't skew or block queries. But the MySQL index still has its own stopword table, and that is what decides whether a token is stored at all. Loading a file in PHP does not change innodb_ft_server_stopword_table / innodb_ft_user_stopword_table, and changing those requires an index rebuild. Verify both sides with a raw query before you file a bug: if SELECT ... MATCH ... AGAINST in the mysql client fails, the package config is not the culprit.
Relevance ordering vs. orderBy
The engine attaches a relevance ordering. A subsequent ->latest() or ->orderBy('created_at') appends to (or in some driver versions overrides) it, producing results that look random. If you want relevance first and recency as a tiebreaker, add your own raw ordering rather than chaining latest():
Post::search($q)
->orderByRaw('MATCH(title, body) AGAINST (? IN NATURAL LANGUAGE MODE) DESC', [$q])
->orderByDesc('created_at')
->get();
Verify the final SQL with ->toSql() in Tinker — one glance saves an hour.
Soft deletes are not part of the index
FULLTEXT indexes cannot encode deleted_at. Scout's shouldBeSearchable() keeps trashed rows out of new index writes, but rows trashed before you added the trait (or via bulk DB::table updates that bypass Eloquent events) linger. Always add ->whereNull('deleted_at') explicitly, or use withoutTrashed() on the search builder.
raw() and scores are not first-class
If the UI needs "relevance: 3.42" or you want to log ranking quality, don't assume the driver exposes it. Add your own selectRaw alongside the search, or inspect ->raw() output. Keep the shape of that query in one place (a scope or a repository method) so a future driver swap doesn't ripple through controllers.
flush, chunk, and cursor semantics
Scout's engine contract includes flush() and model-level removal. Confirm the package implements them usefully — if flush() is a no-op, your scout:flush-based cleanup in tests will silently leave data indexed. Similarly, don't assume search(...)->chunk() behaves like a normal Eloquent cursor: ordering by a computed relevance expression plus offset pagination is slow on large offsets.
Large table behaviour
Adding a FULLTEXT index to a big InnoDB table rebuilds the index; on MySQL 5.7/8.0 use ALGORITHM=INPLACE, LOCK=NONE where supported and rehearse on a replica. Relevance queries also scan more than an equality lookup — cap it with ->take() and always paginate.
Debugging checklist, in order
DB::listen() or Laravel Telescope: is the MATCH ... AGAINST clause actually present in the query?->toSql(): are the columns in the MATCH list identical to that model's index? Are your whereIn() constraints present (i.e. are you on ≥ 5.2.0)?mysql client? (Isolates index vs. Laravel.)scout.driver really sqlout, and is config cached?SELECT COUNT(*) on the table vs. Model::search('')->count() if supported.Extension points worth knowing
The engine is the seam. If you need stemming, synonyms, or typo tolerance, MySQL fulltext cannot give it to you — and that is the honest boundary of this package. For anything beyond substring/phrase/relevance on a modest corpus, plan the Meilisearch or Typesense move. Because it's a Scout driver, that migration is composer require laravel/scout + a driver swap and a re-index, provided you kept all search calls behind Model::search() and didn't sprinkle MATCH ... AGAINST raw SQL across controllers.
Adoption risk
Small package: a handful of stars, zero dependents, single maintainer. 5.2.0 is a genuinely useful release (multi-table indexing, real builder passthrough, configurable stopwords) and no breaking changes are listed — but the whereIn() fix means older versions have a correctness bug. Pin ^5.2 in composer.json, read the engine class once so you can maintain it yourself if needed, and prefer the native whereFullText() fallback for anything load-bearing. For internal tools and small-to-medium datasets it is a genuinely good trade — no extra service to operate — but treat it as a thin, replaceable layer rather than core infrastructure.
How can I help you explore Laravel packages today?