Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Sqlout Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  • The engine class (something like SqlOutEngine) — it is small and readable; read it before debugging anything, and re-read it after 5.2.0 since builder passthrough changed.
  • The package config file — table/index mapping, stopword source, and any per-model options all live there now.
  • The README's example migration; that is the real contract for how columns are matched.
  • 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.


Implementation Patterns

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).
  • Natural-language mode gives you genuine relevance ranking; boolean mode mostly gives you presence/absence. Match the mode to the feature: a search box → natural language; a filter UI → boolean.

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.


Gotchas and Tips

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

  1. DB::listen() or Laravel Telescope: is the MATCH ... AGAINST clause actually present in the query?
  2. ->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)?
  3. Does the same term match in a raw mysql client? (Isolates index vs. Laravel.)
  4. Is scout.driver really sqlout, and is config cached?
  5. Are rows actually in the index — 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment