DEV Community

Renato Maldonado
Renato Maldonado

Posted on

Manticore Speaks MySQL - So I Made It a Laravel Database Driver Instead of a Scout Engine

The problem

I've been working with Manticore Search for about two years at EricaPRO, building the search layer for two financial data platforms. For the past year, that work has been a Laravel API.

Manticore was never the problem. It's fast and it's stable. The problem was the gap between Manticore and Laravel.

I had already built a package for this — laravel-manticore-search, a fluent builder over Manticore's HTTP/JSON API. It works, and it's still in use. But it's a client wrapper. Every feature had to be implemented manually. Every new filter or facet meant more custom architecture around the client, and none of the things Laravel gives you for free — models, migrations, pagination, casts — applied to it.

Scout doesn't close that gap either. Scout gives you search(). No full query builder, no migrations for your indexes, sync is your problem. That's not a criticism — Scout abstracts over engines with completely different APIs, so it exposes the lowest common denominator. It just wasn't what I needed.

What I needed was simple to describe and annoying to not have: something plug and play. Something Laravel way. Point Eloquent at Manticore and use Eloquent.

The insight

Manticore speaks the MySQL wire protocol. Out of the box, port 9306. You can connect to it with any MySQL client and run SQL.

I had been using that port for two years without thinking about what it meant for Laravel.

Because here's the thing: all of Eloquent — models, query builder, migrations, pagination, chunking — sits on top of a Connection and a Grammar. The grammar compiles builder calls into SQL for a specific dialect. That's the entire mechanism behind Laravel supporting MySQL, Postgres, SQLite and SQL Server with one codebase.

So the real question was never "how do I re-implement Eloquent on top of Manticore's client?" It was "how thin can a Manticore grammar be?"

If Manticore accepts MySQL-protocol connections and mostly-MySQL SQL, then a Laravel database driver — a custom connection plus a grammar handling Manticore's dialect quirks — gets you the whole framework. Not a re-implementation. Inheritance.

That's laravel-manticore-eloquent: Manticore registered as a first-class Laravel database connection.

What it looks like

composer require renatomaldonado/laravel-manticore-eloquent
php artisan vendor:publish --provider="ManticoreEloquent\ManticoreEloquentServiceProvider" --tag=config
Enter fullscreen mode Exit fullscreen mode

A model that lives entirely in Manticore extends ManticoreModel. After that, it's just Eloquent:

use ManticoreEloquent\Eloquent\ManticoreModel;

class Article extends ManticoreModel
{
    protected $table = 'articles_rt';
    protected $guarded = [];
    protected $casts = ['published' => 'boolean', 'created_at' => 'datetime'];
}

Article::where('published', true)
    ->match('laravel')          // MATCH() — Manticore full-text
    ->orderBy('created_at', 'desc')
    ->paginate();

Article::find(10);
Article::create(['title' => 'Hello', 'body' => '…', 'published' => true]);
Enter fullscreen mode Exit fullscreen mode

If your model already lives in a relational database and you only want to query it through Manticore, there's a trait instead:

class Company extends Model
{
    use HasManticore;

    public function searchableAs(): string
    {
        return 'companies_rt';
    }
}

Company::query()->where('id', 1)->first();          // relational DB
Company::manticore()->match('fintech')->paginate(); // Manticore
Enter fullscreen mode Exit fullscreen mode

Migrations, but for a search engine

This is the part I missed the most before. Manticore real-time indexes are created with standard Laravel migrations — the schema grammar maps Laravel column types to Manticore types and adds the Manticore-only ones:

Schema::connection('manticore')->create('articles_rt', function (Blueprint $table) {
    $table->text('body');                        // full-text indexed
    $table->string('title');
    $table->integer('views');
    $table->boolean('published');
    $table->timestamp('created_at');
    $table->mva('tag_ids');                      // multi-value attribute
    $table->floatVector('embedding', dims: 384); // vector column for KNN

    $table->minInfixLen(2);                      // infix search
    $table->morphology('stem_en');
});
Enter fullscreen mode Exit fullscreen mode

Your search schema is versioned next to the rest of your schema. php artisan migrate builds it.

Vector search with Eloquent

Manticore supports KNN natively, and since the driver just compiles SQL, it plugs straight into the builder:

Article::manticore()
    ->knn('embedding', $queryVector, k: 10)
    ->selectRaw('*, knn_dist() as distance')
    ->get();
Enter fullscreen mode Exit fullscreen mode

Embedding search with paginate(), casts and everything else Eloquent gives you. No separate vector database in the stack.

The other Manticore-specific helpers cover the rest of the dialect: option() for ranker tuning, facet(), highlight(), replace() for full-document rewrites, and maxMatches() — raised automatically when you paginate past Manticore's default 1,000-row cap, so deep pagination just works.

In production

This isn't a weekend experiment. The driver runs in production in the API I maintain at EricaPRO — the first consumer is a compliance screening service that matches entity and person names against Manticore indexes before calling an external API.

Real usage looks like this:

EntityStakeholderPerson::match($match, '(name)')
    ->when(filled($country), fn ($q) => $q->where('EntityCountryISO', $country))
    ->limit(20)
    ->option('ranker', 'proximity_bm25')
    ->get();
Enter fullscreen mode Exit fullscreen mode

The $match there is a quorum expression built per query — "john doe smith"/2 — so recall stays high on partial names. Different rankers per use case (bm25 for entities, proximity_bm25 for persons), all through the same builder.

Two patterns that fell out of production and might save you time:

A base model for your conventions. Our indexes are prefixed per project and suffixed per environment, and the underlying index is latin1, so a base model handles both once:

class BaseManticoreModel extends ManticoreModel
{
    public function __construct(array $attributes = [])
    {
        $prefix = config('app.project_prefix');
        $suffix = app()->isProduction() ? '' : 'test';
        $this->table = $prefix.$this->table.$suffix;

        parent::__construct($attributes);
    }

    public function getAttributeValue($key)
    {
        $value = parent::getAttributeValue($key);

        if (is_string($value) && ! mb_check_encoding($value, 'UTF-8')) {
            return mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1');
        }

        return $value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Every concrete model is then two lines. That's the point of having real Eloquent models instead of a search client: framework patterns like this just apply.

The parts that don't map cleanly

A search engine wearing a MySQL costume is still a search engine. The seams:

  • No transactions, no row locks. Manticore doesn't have them. lockForUpdate() is a no-op. Don't rely on DB::transaction() semantics on this connection.
  • UPDATE vs REPLACE. Manticore's UPDATE only changes attribute (non-text) columns in place. Rewriting a full document — including text fields — needs REPLACE INTO, so the builder exposes an explicit replace() instead of pretending update() always works.
  • id is special. Manticore owns document ids. Migrations skip any id column you declare.
  • JOINs are limited. Cross-index JOINs in Manticore are restricted. Denormalize your indexes. Eager-loading relations that live on your relational connection works as usual.
  • Emulated prepares. Manticore's server-side prepared statement support is limited, so the driver forces PDO's emulated prepares — bindings, including MATCH(?), are interpolated client-side.

None of these are dealbreakers for search workloads. They're the cost of the approach, and they're documented so nobody discovers them in production.

Try it

PHP 8.1+, Laravel 10/11/12, 95% test coverage enforced in CI. The design notes are in docs/ARCHITECTURE.md if you want the exact SQL differences the grammar handles.

Repo: https://github.com/Renato27/laravel-manticore-eloquent

If you run it against a real workload, I want to hear what breaks. I'm especially interested in edge cases in the grammar — builder combinations that compile to SQL Manticore rejects. Issues and PRs are open.

Top comments (0)