meilisearch/meilisearch-php
The official Meilisearch API client for PHP. Integrate fast, open-source search into your PHP applications with a simple SDK, customizable HTTP clients, and compatibility with Meilisearch servers.
Install the SDK plus a PSR-18 client and PSR-17 factory — the package ships no HTTP client of its own, and Composer will fail loudly if no psr/http-client-implementation is present.
composer require meilisearch/meilisearch-php guzzlehttp/guzzle http-interop/http-factory-guzzle:^1.0
Run Meilisearch (Docker locally, Cloud in prod) and get the master key. A Laravel dev's day-to-day almost always starts in one of two places:
meilisearch driver — this is what you reach for with Eloquent models. Scout already wraps this SDK, so 80% of feature work is model traits + config/scout.php, not direct SDK calls.Meilisearch\Client — for non-Eloquent data (JSON feeds, CMS pages, denormalized search tables), multi-index/federated search, or admin tooling (dumping settings, reindexing, monitoring tasks).First use case — a searchable model via Scout:
// config/scout.php
'driver' => env('SCOUT_DRIVER', 'meilisearch'),
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
App\Models\Movie::class => [
'filterableAttributes' => ['genres', 'year'],
'sortableAttributes' => ['year', 'rating'],
],
],
],
class Movie extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'genres' => $this->genres->pluck('name')->all(),
'year' => $this->year,
];
}
}
Then Movie::search('wondre woman')->get(); — typo tolerance comes free.
Where to look first:
php-sdk.meilisearch.com) for the class surface.v2.0.0-beta.x release notes if you're starting fresh: Indexes → Index, MultiSearchQuery, typed IndexStats.Bind the client as a singleton in a service provider. Don't new Client(...) in controllers; you want one configured instance, timeouts included, and a single place to swap hosts per environment.
$this->app->singleton(Client::class, fn () => new Client(
config('scout.meilisearch.host'),
config('scout.meilisearch.key'),
new \GuzzleHttp\Client(['timeout' => 2, 'connect_timeout' => 1]),
));
The third constructor argument accepts any PSR-18 client — pass Guzzle with a short timeout so a Meilisearch blip doesn't hang a web request.
Treat index settings as code, not as dashboard clicks. The index-settings block in config/scout.php is applied by Scout on scout:sync-index-settings. Keep filterableAttributes, sortableAttributes, searchableAttributes, and rankingRules in version control, and run the sync command in deploy. This is the single biggest operational win over toggling settings in the dashboard.
Use Scout for CRUD models, the SDK for everything else. Scout's model observer hooks keep the index in sync on save/delete. For aggregate data — e.g. a "stores" index built from several tables — write a small indexer that composes arrays and calls $client->index('stores')->addDocuments($chunk).
Chunk large backfills. scout:import handles models, but custom imports should chunk (500–1000 docs per request) and respect the payload limit. Push a batch, collect the task uid, and move on rather than awaiting each one.
Use multi-search for a global search bar. One HTTP round trip across indexes, instead of N:
use Meilisearch\Contracts\MultiSearchQuery;
$client->multiSearch([
(new MultiSearchQuery())->setIndexUid('movies')->setQuery($q)->setLimit(5),
(new MultiSearchQuery())->setIndexUid('people')->setQuery($q)->setLimit(5),
]);
Federated search (v2) lets you merge and weight those into a single ranked result list — good for a unified "search everything" page.
Await tasks deliberately, not everywhere. Writes return a task uid. In HTTP request paths, fire-and-forget is fine. In tests, seeders, and any code that reads its own writes, use $client->waitForTask($task['taskUid']) (or the index-scoped equivalent) so subsequent searches see the data.
Shape search responses for Inertia/Livewire. ->getRaw() gives you the full array (hits, _formatted, estimatedTotalHits, processingTimeMs); ->getHits() gives just the documents. Highlighted _formatted fields are what you render — build a small presenter that picks the formatted values so you never hand-roll <em> tags.
Guard the search key. The master key must never leave the server. For client-side instant search, generate a search-only key via $client->createKey([...]) with actions: ['search'] and an index filter, and expose only that to the browser.
Everything is asynchronous. addDocuments() returns a task, not a result. The classic bug is a test or importer that writes then searches immediately and finds nothing. waitForTask in anything that reads its own writes; nowhere else.
Changing filterableAttributes rebuilds the whole index. Filters silently return zero results if the attribute isn't filterable — this is the #1 support question. Same for sortableAttributes and ->orderBy(). It's a one-time-ish operation, but on large datasets it's minutes of background work; schedule it, don't run it in a request.
Scout's where() is a filter, not SQL. Movie::search()->where('year', '>', 2000) requires year in filterableAttributes. Numeric comparisons use Meilisearch's filter syntax, not query-builder semantics — no joins, no orWhere nesting beyond what the filter grammar supports.
v1 → v2 migration is mostly type changes. Meilisearch\Endpoints\Indexes → Index; $client->getIndex() is now lazy, so existence errors surface on the first getter call, not on construction; fetchInfo()/fetchPrimaryKey() are gone in favour of getters; getUid()/getCreatedAt()/getUpdatedAt() are no longer nullable — drop those !== null guards. Multi-search must use MultiSearchQuery (index uid and federation options moved off SearchQuery). Index stats return a typed IndexStats object.
Pick your HTTP client intentionally. The README's Guzzle 7 recipe (guzzlehttp/guzzle + http-interop/http-factory-guzzle) is the path of least resistance. If you're already on symfony/http-client, pair it with nyholm/psr7. Mixing an old php-http/guzzle6-adapter into a modern Laravel app will cause PSR-7 version conflicts.
Timeouts bite in web requests. Default Guzzle timeouts are long; a slow Meilisearch node will stall your controllers. Set timeout and connect_timeout explicitly, and consider a circuit-breaker/fallback to a database LIKE query for critical paths.
Document IDs must be strings or integers and unique. Scout uses the model key; for custom indexes, ensure your id field is present and stable. A common mistake is letting a UUID-ish string slip into id while filtering numerically elsewhere.
Pagination totals differ. With the default hitsPerPage/page mode you get estimatedTotalHits, which is approximate. If you need exact counts, configure pagination.maxTotalHits and use totalHits — but know it costs performance on big indexes.
Prefix indexes per tenant/environment. Use Scout's searchableAs() to return e.g. tenant_42_movies or staging_movies; otherwise local dev and CI will happily stomp your production index.
Testing: point SCOUT_DRIVER=meilisearch at a throwaway container, call $client->deleteAllIndexes() (or delete specific indexes) in setUp, and always waitForTask after seeding. Never assert against live search results without awaiting — it's the flakiest test suite you'll ever own.
Extension points worth knowing: pass a custom PSR-18 client for mocking in unit tests; use $client->createKey() for scoped API keys; use $client->getTasks(['indexUids' => [...]]) for an ops dashboard; use swapIndexes() for zero-downtime reindexing (build movies_v2, then swap with movies).
How can I help you explore Laravel packages today?