Every PHP team hits the same wall eventually: the search box gets slow, someone profiles it and finds a LIKE '%term%' scan doing a full table read, and the meeting ends with the sentence that has ended a thousand such meetings, “just add Elasticsearch.” In 2012 that was the correct answer, because the alternatives were MySQL’s FULLTEXT, which only worked on MyISAM tables that locked on every write, or Sphinx, which was its own daemon with its own dialect and its own cron-driven index rebuilds, so a Lucene cluster with a real query DSL and near-real-time indexing was worth every gigabyte of JVM heap it wanted. What nobody priced in at the time was the second system you were actually buying, because Elasticsearch is never just a search box; it is a cluster with its own heap sizing, its own mapping language, its own refresh semantics, its own upgrade treadmill, and, most expensive of all, its own copy of your data that someone has to keep almost-consistent with the database that actually owns the truth.
The assumption underneath that reflex is the part worth revisiting: the database cannot search, so search must live elsewhere. That assumption was true for the LAMP stack, and it stayed true long enough to become folklore, but it is a property of specific engines, not of databases in general, and once the engine you already run can do full-text ranking and vector similarity inside the same transactions as your rows, the architecture with three moving parts collapses into one, and most of what you were paying Elasticsearch for turns out to be plumbing you no longer need.
What the second cluster actually costs
The bill for Elasticsearch in a PHP app never arrives as one line item, it arrives as a slow drip of operational surface that nobody put in the estimate. There is the cluster itself, which wants heap sizing and shard counts and a convincing answer to why the yellow status is fine, actually. There is the mapping, which drifts from your schema the first time someone adds a column to the primary table and forgets the reindex. There is the sync path, usually a queue consumer or a Laravel scout observer or a cron that bulk-indexes changed rows, and every one of those has a failure mode where the search index silently falls behind the database and support tickets arrive describing products that exist but cannot be found. And there is the consistency model, because Elasticsearch is near-real-time by design, with a default one-second refresh interval, so “I just saved it, why can’t I find it” is a question you will answer forever.
None of this is a complaint about Elasticsearch as engineering; Lucene is a marvel and the aggregations framework earned its reputation. The complaint is about paying for a second distributed system to solve a problem your primary datastore can now handle, the way you would not run a second Redis just for sessions if your database already did expiring keys.
The same engine, the same transaction
MongrelDB’s bet is that search belongs next to the rows, so the full-text index, the vector index, and the SQL planner all live in one Rust core behind one WAL, which means a commit that writes a product row also makes that row searchable in the same transaction, with no refresh interval and no sync worker to watch. The full-text side is an FM-index with a BM25-inspired ranking function exposed as a SQL UDF, and the vector side is HNSW with swappable backends, and the client you call it all with is pure PHP from Composer, no extension, no compile step:
composer require visorcraft/mongreldb-php
use Visorcraft\MongrelDB\Database;
$db = new Database('http://127.0.0.1:8453', token: $token);
// Substring full-text search, served by the FM-index
$rows = $db->query('products')
->where('fm_contains', ['column' => 3, 'pattern' => 'merino wool'])
->limit(20)
->execute();
When you want ranked results rather than substring matches, the ranking function is ordinary SQL, so it composes with the filters and ordering you already write; the function scores whatever candidate set your WHERE already narrowed, and for corpus-accurate BM25 with global statistics the fts_docs virtual table maintains a real inverted index, since the function itself omits IDF entirely by design:
$results = $db->sql(
"SELECT name, mongreldb_fts_rank(description, 'merino wool socks') AS score
FROM products
WHERE category = 'outdoor'
ORDER BY score DESC
LIMIT 20"
);
The consistency story is the part that matters more than any individual feature, because the indexes are maintained inside the engine’s transaction path rather than fed by an external pipeline, so the moment commit() returns, the row is findable, and the moment it rolls back, it is not; there is no window where the database says one thing and the search tier says another, because there is no search tier.
Where the vectors go
The modern version of this argument is not about full text at all, it is about embeddings, and Elasticsearch has since absorbed that too, with native dense-vector kNN and built-in reciprocal-rank fusion shipping in the 8.x line, so the reflex now buys you one cluster that does both BM25 and ANN. One cluster is still a second system, though, with its own mappings, its own refresh window, and its own copy of your catalog to keep fed, and the question worth asking is whether your primary engine can do the same fusion without the extra household. MongrelDB runs dense-vector HNSW in the same engine and exposes hybrid retrieval as one call from PHP, with reciprocal-rank fusion across retrievers and an optional exact-vector rerank over the fused candidates:
$hits = $db->search('products')
->annRetriever('semantic', 4, $queryEmbedding, k: 64, weight: 1.0)
->sparseRetriever('lexical', 5, $termWeights, k: 64, weight: 1.0)
->fusion(60)
->exactRerank(4, $queryEmbedding, candidateLimit: 64)
->limit(10)
->execute();
foreach ($hits['hits'] as $hit) {
// fused, ranked rows from one POST to /kit/search
}
The two retrievers each return their own top-64, the fusion step merges the ranked lists with the classic RRF constant of 60 from the Cormack paper so a row that ranks well in both lists floats to the top, and the exact rerank then recomputes true cosine similarity over the fused candidates, which buys back the precision that approximate graph search trades away. That single call replaces what used to be an Elasticsearch query, a vector database query, and a merge function in application code, and it does so behind the same bearer token, the same HTTP transport, and the same backup story as everything else in the table, which matters more than it sounds the first time you have to restore last Tuesday’s catalog and would like the search index to come back with it.
Where Elasticsearch still wins
Honesty is cheaper than hype, so: Elasticsearch still earns its cluster when the workload is genuinely its shape. If you are ingesting terabytes of logs a day and your primary question is aggregations over time buckets, Lucene’s columnar engines and the whole Kibana-and-Grafana ecosystem around them are the right tool, and nothing in MongrelDB pretends otherwise. If your organization already runs ELK well, with people who know shard allocation from personal pain, the switching cost is real and the payoff is smaller than the pitch decks say, and percolator-style reverse search has no equivalent here at all. The claim is narrower and, for a PHP shop, more useful: when the job is “search my products, my posts, my customers, and now their embeddings,” the second cluster is optional in a way it was not in 2012, and optionality over infrastructure is the kind of savings that compounds quietly, one un-paged weekend at a time.