avadim / manticore-laravel-scout
ManticoreSearch driver for Laravel Scout
Package info
github.com/aVadim483/manticore-laravel-scout
pkg:composer/avadim/manticore-laravel-scout
Requires
- php: ^8.2
- ext-json: *
- avadim/manticore-query-builder-laravel: ^3.0
- avadim/manticore-query-builder-php: ^2.4
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- laravel/scout: ^11.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5|^11.0|^12.0|^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
English | Русский
ManticoreSearch driver for Laravel Scout
Full-text search of your Eloquent models in ManticoreSearch, through
the API of Laravel Scout: Post::search('manticore')->get().
composer require avadim/manticore-laravel-scout
The driver is a thin layer over
avadim/manticore-query-builder-laravel:
the connection, the pool and the SQL belong there, and so does config/manticore.php. This package
translates what Scout asks for into a query of the builder and maps the answer back onto models.
Contents
- Related packages
- Requirements
- Installation
- Configuration
- Quick start
- The schema of an index
- Keeping an index in line with its schema
- What a search understands
- Options of a query
- Semantic and hybrid search
- The query language of Manticore
- Soft deletes
- Artisan commands
- Limits worth knowing
- Tests
Related packages
avadim/manticore-query-builder-laravel— the Laravel integration this driver stands on, and where its connection comes from:config/manticore.php, named connections, the\ManticoreDbalias and the facade. Reach for it whenever a query needs more than the API of Scout gives.avadim/manticore-query-builder-php— the query builder itself, with no dependency on Laravel: the syntax of a query and the schema DSL live there, and so does everything this driver translates a search into.
Requirements
- PHP >= 8.2
- Laravel 11 - 13 (or Lumen of the same generation), Laravel Scout 11
- ManticoreSearch 7.0 or above, with the MySQL protocol open (port 9306 by default). That is where the CI runs — 7.0.0, 13.11.1 and the current release — and older is untested rather than known to break: the registry no longer carries an image of it. The semantic search asks for more, see below.
avadim/manticore-query-builder-laravel>= 3.0
Installation
composer require avadim/manticore-laravel-scout
Both service providers are discovered by Laravel on their own. Publish the config of the query builder package and, if you want the defaults of the driver in writing, the config of this one:
php artisan vendor:publish --provider="avadim\Manticore\Laravel\ServiceProvider" --tag=config php artisan vendor:publish --provider="avadim\Manticore\Scout\ServiceProvider" --tag=config
The second command writes config/scout.manticore.php, and the dot of that name is not a typo:
the config loader of Laravel takes the key of a file from its name and sets it with the dot
notation, so the file is read as the manticore section of config/scout.php rather than as a
config of its own. Publishing it is optional - the same keys can be written into config/scout.php
by hand, and what is written wins over the defaults of the package either way.
In Lumen, register the providers in bootstrap/app.php and copy the config files by hand.
Configuration
The connection is the one of config/manticore.php:
'connections' => [ 'default' => [ 'host' => env('MANTICORE_HOST', '127.0.0.1'), 'port' => env('MANTICORE_PORT', 9306), // ... ], ],
Scout itself needs two lines in .env:
SCOUT_DRIVER=manticore SCOUT_QUEUE=true
Everything the driver reads lives in the manticore section of config/scout.php - written there
by hand, or published as config/scout.manticore.php, which Laravel reads into the same section.
The defaults are merged in by the service provider, so only the keys you change have to be written
down:
| Key | Env | Default | Meaning |
|---|---|---|---|
connection |
SCOUT_MANTICORE_CONNECTION |
null |
name of a connection of config/manticore.php; null takes the default one |
limit |
SCOUT_MANTICORE_LIMIT |
1000 |
the limit of a search that says nothing about it - the server would answer with 20 rows |
max_matches |
SCOUT_MANTICORE_MAX_MATCHES |
null |
rows the server keeps per query, i.e. how deep paging goes and how far total() counts |
escape_query |
SCOUT_MANTICORE_ESCAPE_QUERY |
true |
escape the phrase, so that what a user typed is searched for as it was written |
auto_create |
SCOUT_MANTICORE_AUTO_CREATE |
true |
create the index on the first write to it; off, a write to an index that is not there raises |
auto_columns |
SCOUT_MANTICORE_AUTO_COLUMNS |
true |
add a column the index is missing and write again |
batch_size |
SCOUT_MANTICORE_BATCH_SIZE |
100 |
rows of one REPLACE; 0 turns the limit off |
schemas |
- | [] |
schemas of the indexes, by index name |
index-settings |
- | [] |
the indexes scout:sync-index-settings walks; empty means the ones of schemas |
semantic |
- | see below | the vector column, the neighbours asked for, and the embedder of semantic() and hybrid() |
Quick start
use Illuminate\Database\Eloquent\Model; use Laravel\Scout\Searchable; class Post extends Model { use Searchable; public function toSearchableArray(): array { return [ 'title' => $this->title, 'body' => $this->body, 'author_id' => (int)$this->author_id, 'created_at' => $this->created_at->getTimestamp(), ]; } }
php artisan scout:import "App\Models\Post"
Post::search('manticore')->get(); Post::search('manticore')->where('author_id', 7)->orderBy('created_at', 'desc')->paginate(20); Post::search('manticore')->keys(); Post::search('manticore')->cursor();
Saving a model writes it to the index, deleting it removes it - that is the observer of Scout, not
this package. SCOUT_QUEUE=true moves both onto a queue.
The schema of an index
Manticore has no schema of its own to fall back on: a write to a table that does not exist is an error, and so is a column the table has no place for. The driver deals with that in three ways, in this order.
1. The config. Written down once, the same as a migration:
// config/scout.php 'manticore' => [ 'schemas' => [ 'posts' => [ 'columns' => [ 'title' => 'text', 'body' => 'text', 'author_id' => 'int', 'created_at' => 'timestamp', ], 'options' => ['min_infix_len' => 3], ], ], ],
This is what php artisan scout:index posts creates; without it the command says where to put a
schema instead of creating an empty table.
2. The model. A manticoreSchema() keeps the schema next to toSearchableArray():
public function manticoreSchema(): array { return [ 'title' => 'text', 'body' => 'text', 'author_id' => 'int', 'slug' => 'string', ]; }
With auto_create off, a write to an index that is not there raises instead - and the driver asks
the server whether the table is there rather than letting the write answer that, because Manticore
creates a table out of its own guess on a write from version 29 on, which is what turning
auto_create off is meant to prevent.
3. The values. With neither of the two, the index is built out of the first rows written to it:
a string becomes a text field, an integer a bigint, a float a float, a bool a bool, an array
a json. Good enough to start with, and enough of a guess that a column you filter or sort by is
better described by hand - see the limits below.
A column added to toSearchableArray() later is added to the index as well (auto_columns), and
the rows written before it keep an empty value for it until they are imported again.
Keeping an index in line with its schema
A schema changes: a column is added to toSearchableArray(), min_infix_len turns out to be
needed. The command of Scout brings the index to what the schema says:
php artisan scout:sync-index-settings
It walks the indexes named in scout.manticore.index-settings and, when that is empty, the ones of
schemas. A column the index does not have is added and the options of the table are applied; a
column that is already there keeps the type it has, because Manticore cannot change one in place
without losing what is written in it - a changed type is a matter of a new index and an import
into it.
index-settings is a list of index names and model classes, or a map of them to a schema of their
own, which wins over schemas key by key:
// config/scout.php 'manticore' => [ 'index-settings' => [ 'posts', \App\Models\Comment::class, 'pages' => [ 'columns' => ['slug' => 'string'], 'options' => ['min_infix_len' => 3], ], ], ],
The same happens to one index at a time on php artisan scout:index posts.
What a search understands
The builder of Scout is a small one, and all of it works here:
Post::search('manticore') // WHERE MATCH('manticore') ->where('author_id', 7) // AND author_id = 7 ->where('rating', '>', 4) // AND rating > 4 ->whereIn('category_id', [1, 2, 3]) // AND category_id IN (1, 2, 3) ->whereNotIn('status', [0]) // AND status NOT IN (0) ->orderBy('created_at', 'desc') // ORDER BY created_at DESC ->take(50) // LIMIT 50 ->get();
Without orderBy(), the rows come back in the order the server ranked them, and every model
carries the weight of its row:
$post->scoutMetadata()['_score'];
paginate() and simplePaginate() answer with the paginators of Laravel. A page beyond the 1000
rows Manticore keeps per query raises max_matches on its own, so paging does not break on page 51.
Options of a query
options() of the Scout builder is read by the driver first, and what is left of it becomes the
OPTION clause of the SELECT:
Post::search('manticore')->options([ 'fields' => ['title'], // search these full-text fields only 'escape' => false, // pass the query language through, see below 'highlight' => true, // or ['options' => [...], 'fields' => [...]] 'ranker' => 'sph04', // OPTION ranker=sph04 'field_weights' => ['title' => 10], // OPTION field_weights=(title=10) 'cutoff' => 1000, // OPTION cutoff=1000 ])->get();
A highlight comes back as metadata of the model:
$post->scoutMetadata()['_highlight'];
Anything the driver does not cover is reached through the callback of search(), which gets the
query of the builder itself:
use avadim\Manticore\QueryBuilder\Query; Post::search('manticore', function (Query $query, string $phrase) { return $query->whereKnn('embedding', 5, $vector)->facet('author_id'); })->get();
Return the query and the driver runs it, or run it yourself and return the ResultSet.
The engine itself hands anything it does not know of to the connection of the query builder, which
is where the transactions, the DESCRIBE and the answer of the last statement live:
use Laravel\Scout\EngineManager; $engine = app(EngineManager::class)->engine('manticore'); $engine->transaction(function () { /* ... */ }); $engine->tableDescribe('posts'); Post::search('manticore')->get(); $engine->lastResultSet()->facets(); // the meta of the search that has just run
$engine->connection() is the same connection, asked for by name rather than through the
forwarding - and \ManticoreDb::connection() of the query builder package answers with it too.
Semantic and hybrid search
Manticore searches by vectors as well as by words, and takes both in one statement - which is what makes a hybrid search one query here rather than two and a merge of the answers.
Two things are needed for it: a float_vector column in the index, written along with the model,
and something that turns the phrase of a search into a vector.
The column asks something of the server as well. Vectors came with ManticoreSearch 6.3, and the KNN
of it is a library of its own: a build without that library takes the CREATE TABLE and answers
knn library not loaded. What the server has is in its own words:
SHOW STATUS LIKE 'version'; -- 29.0.2 ... (columnar 13.9.0 ...) (secondary 13.9.0 ...) (knn 13.9.0 ...) (embeddings 1.1.1 ...)
The knn in there is the one that matters — the official image carries it, and so does every
release the CI of this package runs the vector tests against.
// config/scout.php 'manticore' => [ 'semantic' => [ 'column' => 'embedding', 'k' => null, // neighbours asked for; null: what the page needs 'embedder' => \App\Search\Embedder::class, ], ],
The embedder is a callable, or the name of an invokable class the container builds. It is given the phrase and the model, and answers with an array of numbers:
class Embedder { public function __invoke(string $phrase, $model): array { return $this->vectors->of($phrase); } }
The column belongs in the schema of the index, where a vector takes more than a type name - hence the callable form of a schema:
use avadim\Manticore\QueryBuilder\Schema\SchemaTable; public function manticoreSchema(): callable { return function (SchemaTable $table) { $table->text('title'); $table->integer('author_id'); $table->floatVector('embedding', 1536, 'cosine'); }; }
and in toSearchableArray(), as the vector of the model itself:
'embedding' => $this->embedding, // an array of floats
Then the two searches of Scout answer:
Post::search('a fruit that keeps the doctor away')->semantic()->get(); Post::search('apple')->semantic(0.8)->get(); Post::search('apple')->hybrid(1, 2)->get();
semantic() searches by the vector alone: WHERE knn(embedding, k, (…)), and semantic(0.8) adds
the similarity the row has to reach. hybrid($textWeight, $semanticWeight) asks for both at once,
and MATCH() is a condition of its own - a hybrid search keeps to the rows carrying the words, and
ranks them by <text weight> * weight() + <semantic weight> * (1 - knn_dist()). An orderBy() of
your own is left alone.
What the server answered with is on the model, next to the rest of the metadata:
$post->scoutMetadata()['_knn_dist']; // the distance, 0 being the vector itself $post->scoutMetadata()['_similarity']; // 1 - the distance, i.e. 0 to 1 for a cosine index $post->scoutMetadata()['_hybrid_score']; // of a hybrid search, what it was ranked by
The weight of a full-text match is the score of the ranker - in the thousands - while the
similarity is 0 to 1, so the weights of hybrid() are what brings the two to one scale.
The settings are given per query as well, next to the other options:
Post::search('apple')->semantic()->options([ 'semantic' => ['column' => 'title_vector', 'k' => 100], ])->get();
k is the number of neighbours the server looks at before anything else narrows the result, so a
where() or the words of a hybrid search cut into those k rows rather than into the whole index.
Left alone it is as deep as the page reaches.
The query language of Manticore
What a user typed into a search box is text, not an expression: a dash in iPhone -Pro would
exclude "Pro", a pipe would turn into an OR, and an unpaired quote makes the server reject the
query outright. The driver escapes the phrase for that reason, so a search finds what was written.
To use the language on purpose - alternatives, phrases, field operators - turn the escaping off, either for a query or in the config:
Post::search('"quick brown fox"/2 -lazy')->options(['escape' => false])->get();
Soft deletes
With scout.soft_delete on, a trashed model stays in the index behind the __soft_deleted flag,
and the search of Scout works as it does everywhere else:
Post::search('manticore')->withTrashed()->get(); Post::search('manticore')->onlyTrashed()->get();
The column is written and filtered by the driver; with the schema of an index in the config, add it
yourself as '__soft_deleted' => 'int'.
Artisan commands
The commands of Scout work as they do with any other driver:
php artisan scout:import "App\Models\Post" # write every model to the index php artisan scout:flush "App\Models\Post" # empty the index, keep the table php artisan scout:index posts # create the index of the config schema php artisan scout:sync-index-settings # add what an index is missing of its schema php artisan scout:delete-index posts # drop the table php artisan scout:delete-all-indexes # drop every table whose name carries scout.prefix
scout:delete-all-indexes with an empty scout.prefix reaches every table of the server, tables of
this application or not - the same as the other drivers of Scout do it.
Limits worth knowing
The key of a model has to be a positive integer. A document id of Manticore is a bigint, so a
UUID or a string key is rejected with an exception naming the model. Give such a model an integer
getScoutKey().
A text column is searched, not filtered. It is the full-text field of Manticore: search()
finds words in it, but where() and orderBy() need an attribute - int, bigint, float,
bool, string, timestamp. A guessed schema makes every string a text field, which is why a
column you filter by belongs in a schema of the config or of the model.
total() counts up to max_matches. The server keeps 1000 rows per query by default; a total
beyond that is the limit itself until max_matches is raised. What the config says is the depth of
every query, not only of a page that reaches beyond it - a page deeper than that raises it for
itself.
The schema cache lives as long as the connection. In Octane or a queue worker that is a long
time; a table changed elsewhere calls for \ManticoreDb::forgetSchema(), or for
forgetSchemas() of the manager, which reaches every connection it built rather than the default
one alone. The connection itself is dropped by name with purge() and opened again by
reconnect() - what a worker whose handle the server closed overnight needs:
use avadim\Manticore\Laravel\Manager; app(Manager::class)->forgetSchemas(); app(Manager::class)->reconnect();
Tests
composer install php -d xdebug.mode=off vendor/bin/phpunit
The tests of the search itself need a ManticoreSearch at 127.0.0.1:9306
(MANTICORE_TEST_HOST / MANTICORE_TEST_PORT in phpunit.xml.dist); they are skipped when there
is none. Tables of a test are named phpunit_<uniqid>_* and dropped afterwards.
License
MIT