devilsberg/laravel-mariadb-vector
MariaDB 11.7+ native vector storage and similarity search for Laravel. Adds a VECTOR column type, binary-safe Eloquent casts, and nearestNeighbors() query macros powered by VEC_DISTANCE functions — essentially pgvector, but for MariaDB.
Gate on prerequisites before you write a line of code. Three hard requirements: PHP 8.4+, Laravel 12+, MariaDB 11.7+. The MariaDB version is the only one that routinely blocks people — 11.4 is the LTS, and the native VECTOR type plus VEC_DISTANCE_* functions do not exist there. Verify with:
SELECT VERSION(); -- must be >= 11.7
SHOW CREATE TABLE t; -- sanity: VECTOR type available
If you are on MySQL 8, 10.x, or 11.4 LTS, stop here; this package is a follow-on to a database upgrade, not a substitute for one.
Install and publish.
composer require devilsberg/laravel-mariadb-vector
php artisan vendor:publish --tag=mariadb-vector-config
Pin a floor of ^1.2.1 — 1.2.1 fixes a binary/JSON misdetection bug in VectorCast::get() that corrupts roughly 1 in 256 reads. Details below.
Where to look first, in order:
config/vector.php — two keys, default_dimensions and distance_metric. Set dimensions to match your embedding model now; migrations that rely on the default will silently drift otherwise.Devilsberg\LaravelMariadbVector\Casts\VectorCast — the whole storage contract. Read set() and get(); they are short.Devilsberg\LaravelMariadbVector\Distance — the enum you pass to nearestNeighbors.Illuminate\Database\Eloquent\Builder — that is the entire public query surface.First use case: a vertical slice on an existing table. Don't start greenfield. Take a table you already have (articles), add a nullable vector column, backfill it from a real embedding provider, and prove one ranked query end to end. That exercises the cast, the SQL generation, and the score semantics in about an hour.
Schema::table('articles', function (Blueprint $table) {
$table->vector('embedding', 768)->nullable(); // nullable while backfilling
});
class Article extends Model
{
protected function casts(): array
{
return ['embedding' => \Devilsberg\LaravelMariadbVector\Casts\VectorCast::class];
}
}
Article::query()
->whereNotNull('embedding')
->nearestNeighbors('embedding', $queryVector, Distance::Cosine)
->limit(10)
->get();
Once the backfill completes, flip to ->nullable(false) and add $table->vectorIndex('embedding') in a second migration. That ordering matters — see the NOT NULL gotcha.
Keep embedding generation behind a contract. This package deliberately does storage and search only; it will never call your provider. Define the boundary yourself so you can swap Ollama for Mistral without touching models:
interface Embedder {
public function embed(string $text): array;
}
// AppServiceProvider
$this->app->bind(Embedder::class, OllamaEmbedder::class);
Then a single trait keeps models boring:
trait HasEmbedding
{
public function embedWith(Embedder $embedder): static
{
$this->embedding = $embedder->embed($this->body);
return $this;
}
}
Backfill with a queued job, chunked, idempotent. Never embed synchronously inside a request loop. chunkById plus a whereNull('embedding') filter means a re-run resumes rather than restarts:
Article::query()->whereNull('embedding')->chunkById(200, function ($batch) use ($embedder) {
foreach ($batch as $article) {
$article->embedding = $embedder->embed($article->body);
$article->saveQuietly(); // skip observers/events during bulk work
}
});
Remember that Article::query()->update([...]) applies casts, but DB::table('articles')->update([...]) does not — it will try to write a PHP array as a string. Route all vector writes through Eloquent or through an explicit VEC_FromText(?) raw expression.
Prefer nearestNeighbors for ranking, the three granular macros for filtering. nearestNeighbors is the ergonomic path: one score column, ordered descending, no sign-flipping in your head. Reach for whereVectorSimilarTo + orderByVectorDistance only when you need a hard threshold or a narrow projection:
Article::query()
->select('id', 'title')
->selectVectorDistance('embedding', $queryVector, as: 'score')
->whereVectorSimilarTo('embedding', $queryVector, 0.4) // note: DISTANCE, not score
->orderByVectorDistance('embedding', $queryVector)
->limit(10)
->get();
Over-fetch and post-filter for hybrid search. Vector similarity does not compose well with WHERE category_id = ? at the index level — the ANN index gives you a candidate set, then your filters cut it down. Pull 3–5× your target limit, filter in SQL or in the collection, and slice:
$candidates = Article::query()
->where('published', true)
->nearestNeighbors('embedding', $queryVector, Distance::Cosine)
->limit(50)
->get()
->take(10);
Treat score as an opaque ranking number, not a probability. Normalize at the resource layer if you must expose it to clients, and document the direction (higher = more similar). Do not threshold on score in one place and on whereVectorSimilarTo's threshold in another — they are inverses.
Version your embeddings. Model upgrades produce vectors that are not comparable to old ones. Add embedding_model (string) and embedded_at (timestamp) alongside the column, filter queries by model version, and run a re-embed job per version rather than an in-place overwrite. Changing dimensions later requires a new column plus a full rebuild — the vector(n) type is fixed-width.
Testing pattern. Unit tests do not touch SQL, so they will not catch a broken macro. Stand up MariaDB 11.7 in CI (a service container is enough) and write one integration test per macro that asserts result order, not just row count:
$this->assertSame([3, 1, 2], $results->pluck('id')->all());
Guard those tests with a version check so contributors on 11.4 get a skip rather than a red build.
The repository is archived. Nine stars, zero dependents, single maintainer, no downstream production evidence. Pin an exact version in composer.json, and keep all usage behind your own repository or service class so a fork or replacement is a one-file change. Budget a day to vendor the three pieces you actually depend on if the migration becomes load-bearing.
Vector indexes require NOT NULL. $table->vectorIndex('embedding') on a nullable column raises a database error. Add the index in a separate migration after backfilling and setting the column non-nullable.
One vector index per table, period. MariaDB 11.7 will not accept a second VECTOR INDEX. If you genuinely need ANN over two columns, that is two tables — plan the schema accordingly rather than discovering it in production.
DOT is not supported. Setting distance_metric to DOT throws InvalidArgumentException. COSINE and EUCLIDEAN are the real options. If you need dot-product ranking, normalize your embeddings to unit length and use cosine — the rankings are equivalent.
Threshold and score point in opposite directions. whereVectorSimilarTo('embedding', $v, 0.5) means cosine distance < 0.5, i.e. similarity > 0.5. nearestNeighbors's score is 1.0 - distance, so 0.5 distance ⇒ 0.5 score, but 0.2 distance ⇒ 0.8 score. Mixing the two in one query is the most common source of "why is my filter returning the wrong rows."
Euclidean score normalization assumes unit vectors. 1.0 - distance / SQRT(2) clamps to roughly [0,1] only for normalized inputs; unnormalized embeddings can push scores negative. Cosine scores for LLM embeddings land in [0,1] in practice but are formally [-1,1]. Do not assume a [0,1] range in your UI without clamping.
Verify the ANN index is actually used. The recommended nearestNeighbors macro orders by the score alias. MariaDB's optimizer should recognize the underlying VEC_DISTANCE_COSINE expression, but ordering by an alias is exactly the kind of thing that defeats index selection. Run EXPLAIN on your real query and confirm the vector index appears. If it does not, drop to selectVectorDistance(...) + orderByVectorDistance(...), which orders by the raw expression. This is worth ten minutes on any dataset large enough to care about.
Always limit(). Without a limit, a similarity query scores every row and sorts the whole table — the vector index gets you nothing. Treat ->limit() as mandatory, not stylistic.
Macros live on the Eloquent Builder only. DB::table('articles')->nearestNeighbors(...) will throw. If you need raw query builder access, register your own mirror macro or use selectRaw with VEC_DISTANCE_COSINE(\embedding`, VEC_FromText(?))`.
Don't chain select() before nearestNeighbors(). The macro prepends SELECT *, .... If you need a narrow projection with a score, use selectVectorDistance() explicitly instead of mixing the two APIs.
Binary/JSON detection was fragile before 1.2.1. MariaDB returns vectors as packed 32-bit floats; VectorCast also accepts JSON strings. The original detection looked only at the first byte, so any binary vector starting with 0x5B ([, ~1 in 256) was misparsed as JSON and threw. Fixed in 1.2.1 by validating the whole string and falling back for lengths divisible by 4. Pin ^1.2.1. If you are stuck on an older pin, this surfaces as an intermittent InvalidArgumentException on read that no amount of re-saving fixes.
Empty arrays become null. VectorCast::set([]) writes null rather than a zero-length vector — convenient for nullable columns, surprising if you expected a round-trip guarantee. Filter ->whereNotNull('embedding') before ranking or you will hit null-distance comparisons.
Config caching bites after publishing. config/vector.php is read at macro-registration time for the default metric. Run php artisan config:clear after editing, and remember that per-call metric: / Distance arguments override the config — prefer explicit arguments in hot paths so a config change cannot silently reorder your search results.
Storage is not free. 768 dimensions × 4 bytes ≈ 3 KB per row before index overhead. A million rows is ~3 GB of table plus the ANN index. Do not add a vector column to a table you also select with SELECT * on every page load — use explicit column lists in non-search queries.
CI without MariaDB 11.7 will fail at migrate time, not at query time, which is a confusing error. Gate the migration with a driver/version check in AppServiceProvider::boot() or keep vector migrations in a separate directory that only the integration suite runs.
Extension points if you outgrow it. Builder::macro() is open — you can add your own compositions (e.g. a similarWithin that combines threshold and limit consistently). The cast is a single class; subclass it if you want to normalize vectors on write. Everything else is plain SQL that ports to any VEC_DISTANCE_*-capable database, which is the package's real long-term value: the knowledge is portable even if the package is not.
How can I help you explore Laravel packages today?