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

Manticore Query Builder Php Laravel Package

avadim/manticore-query-builder-php

A fluent, Laravel-style query builder for Manticore Search in PHP. Compose SELECT, INSERT, UPDATE, DELETE, and full-text search queries with an expressive API, manage indexes and schemas, and integrate cleanly into existing PHP or Laravel projects without hand-writing SphinxQL.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and pin.

composer require avadim/manticore-query-builder-php

Before writing code, open composer.json (PHP constraint — this matters on older Laravel 9/10 boxes) and then read the README's method list top to bottom. It's a small package; the entire API surface fits on one screen. Then skim src/ and tests/ — the tests are the real documentation because they show every clause the builder emits.

2. Where to look first.

  • src/ — the builder class. You want the methods that map to ManticoreQL: table()/from(), match(), where()/andWhere()/orWhere(), orderBy(), groupBy(), limit()/offset(), option(), and the terminal toSql().
  • The README examples for anything OPTION-related (ranker, max_matches, field_weights) — that's where Manticore-specific behaviour lives and where a generic builder will feel thin.
  • The ManticoreQL reference in Manticore's own docs. The builder emits text; the docs define the target.

3. First use case: replace a slow LIKE '%term%' product search.

use Avadim\Manticore\QueryBuilder\Builder;

$sql = (new Builder())
    ->table('products')
    ->match('title,description', 'wireless headphones')
    ->where('price', '<=', 200)
    ->where('in_stock', 1)
    ->orderBy('price', 'ASC')
    ->limit(20)
    ->toSql();

// -> SELECT * FROM products WHERE MATCH('wireless headphones')
//    AND price <= 200 AND in_stock = 1 ORDER BY price ASC LIMIT 20

Execute it against the HTTP /sql endpoint — you don't need the package to do that:

$result = Http::withBody($sql, 'text/plain')
    ->timeout(2)
    ->post(config('manticore.dsn').'/sql')
    ->json();

// $result['hits']['hits']        -> rows
// $result['hits']['total']       -> count
// $result['error']               -> check this, see gotchas

Bind a configured singleton once so $sql construction is the only thing in your controllers:

// AppServiceProvider::register()
$this->app->singleton(Builder::class, fn () => (new Builder())->table(config('manticore.default_index')));

The value of this package is precisely that it returns a string. It is not an ORM, not an active-record layer, not a connection pool. That makes it trivial to log, cache, snapshot-test, and hand to whatever HTTP client you already trust. Treat it that way.

Implementation Patterns

Config + a thin repository layer. Put host, port, default_index in config/manticore.php, then wrap every query in one class (App\Search\ProductSearch) with methods like search(SearchRequest $r): SearchResult. Nothing outside that class should import Avadim\Manticore. This single indirection is what lets you swap to manticoresoftware/manticoresearch-php later without touching controllers, and it's non-negotiable given the package's adoption level.

Build/execute separation. Keep toSql() and the HTTP call in separate methods. You get free observability: log the SQL on slow requests, Http::fake() the execution in tests while asserting exact SQL strings, and reuse the same builder for a EXPLAIN/SHOW META companion query.

Hydrate back into Eloquent or DTOs. Manticore returns flat hits with a _id and your stored fields. Map them explicitly:

$hits = collect($result['hits']['hits'])->map(fn ($h) => new ProductHit(
    id: (int) $h['_id'],
    title: $h['_source']['title'],
    score: $h['_score'],
));

Avoid Model::hydrate() on raw search output — you'll get partial models and surprise nulls on save. If you need full models, collect IDs and do one whereIn against MySQL (a two-phase "search then hydrate" pattern, which is also what you want when you need relationships).

Facets = a second query. Don't try to make one query do both. Run the main hit query, then a groupBy('brand')->groupBy('category') query with COUNT(*) for the sidebar, then merge. Manticore's GROUP BY returns only group keys plus aggregates — no rows — so this is cheap and predictable.

Indexing pipeline. Manticore is a read model. Do not dual-write synchronously inside the request. Use a model observer → queued job → /bulk (newline-delimited JSON, not SQL) or REPLACE INTO. Add php artisan manticore:reindex {--chunk=1000} that truncates and re-pushes from MySQL, and schedule a reconciliation check on count(*) diff. You will need this the first time a bulk push half-fails.

Pagination. There is no paginate(). Compute total from the response, slice with limit/offset, and construct new LengthAwarePaginator($hits, $total, $perPage, $page). Remember max_matches (see gotchas) before you promise users page 50.

Testing. Two layers: (a) pure unit tests asserting toSql() output for each filter combination — fast, no server, catches escaping regressions; (b) a Docker manticoresearch/manticore service in CI for a handful of integration tests. Do not use SQLite or a mock in place of (b); the whole point of the package is emitting syntax the server must accept.

Scout? If you're already on Laravel Scout, note that a Scout engine has to translate Scout's Builder closures into your syntax — that adapter is more code than the search itself. This package is the right tool for bespoke endpoints (faceted catalog search, autocomplete, percolate/alerting); for plain Searchable model search, an existing Manticore Scout engine will cost you less maintenance.

Gotchas and Tips

MATCH() is not LIKE. Special characters (()|-!@~"&/^$=) carry operator meaning and a bare user string will either error or silently negate terms. Sanitize input or use CALL KEYWORDS / ESCAPE before it reaches match(). Never interpolate raw request input into match().

Attribute vs. field confusion. where('title', $x) fails — title is a full-text field, not an attribute. Full-text predicates go through MATCH(); WHERE only filters attributes (numbers, strings declared as attributes, MVA, timestamps). This is the single most common source of "it works in MySQL, not here".

Deep pagination dies at max_matches. Default is 1000. offset(5000) returns empty, not an error. Either raise max_matches in OPTION (costs memory per query) or switch to search-after/id > last_id keyset pagination.

Relevance ordering needs weight(). ORDER BY weight() DESC combined with OPTION ranker=bm25 (or proximity, wordcount) — the default ranker is fine but you must ask for the ordering explicitly.

/sql can return HTTP 200 with an error key. Always check $response['error'] before touching hits. Wrap this in your repository so a malformed query surfaces as an exception with the offending SQL attached — otherwise you'll debug silent empty result sets for an hour.

total vs total_found. The default total is capped and approximate in some modes. For accurate counts (facet badges, "1,234 results"), issue SHOW META after the query or enable the accurate-count option — and accept the latency cost.

_source may not contain what you expect. Only stored fields come back. If you added a field after index creation without ALTER TABLE ... ADD COLUMN, it silently won't be there.

IDs must be unsigned 64-bit, non-zero. REPLACE with id = 0 inserts nothing useful; delete-then-insert semantics will drop your MVA and JSON attributes. Use REPLACE deliberately, not as a reflex.

No transactions. Two writes to Manticore are two writes. Any multi-index update needs idempotent replay from a queue plus the reconciliation job above.

HTTP client details. Http::withBody($sql, 'text/plain') — a plain Http::post($url, $sql) will JSON-encode the string and fail. Set a short timeout (search should degrade, not hang), and reuse a Guzzle client with keep-alive; Manticore's HTTP endpoint is stateless and benefits more from connection reuse than from pooling.

Package risk, stated plainly. Eight stars, zero dependents, single maintainer, "unofficial" in the description, an anomalous release date in the metadata. The code is MIT and small enough to vendor or fork in an afternoon — do one of those, or keep it strictly behind your App\Search interface. Never let Avadim\Manticore\* types leak into controllers, jobs, or tests.

Server version drift. Manticore has been adding features fast (JOIN, AGGS, percolate variants). Pin the Docker image tag in dev and CI to the same minor version as production, and verify every advanced clause against that version before shipping — the builder will happily emit syntax an older daemon rejects.

Extension point. If you need something the builder doesn't model (a custom OPTION, FACET, SNIPPET() in the select list), prefer appending with a raw()/whereRaw()-style escape hatch over forking — then write a unit test pinning the emitted string so a package update that changes formatting breaks your test, not production.

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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings