-
Notifications
You must be signed in to change notification settings - Fork 2
MeilisearchClient
Pair\Services\MeilisearchClient is a lightweight HTTP client for optional Meilisearch search integrations.
The Pair core does not require the Meilisearch PHP SDK. The client uses cURL directly and focuses on the operations Pair applications usually need:
- create and configure indexes
- index read-model documents
- delete documents
- run keyword, filtered, faceted, paginated, vector, and hybrid searches
- inspect asynchronous Meilisearch tasks
MEILISEARCH_HOST="http://127.0.0.1:7700"
MEILISEARCH_API_KEY=
MEILISEARCH_DEFAULT_INDEX=
MEILISEARCH_TIMEOUT=10
MEILISEARCH_CONNECT_TIMEOUT=3
MEILISEARCH_SEARCH_LIMIT=20Keys:
-
MEILISEARCH_HOSTis the Meilisearch server URL. -
MEILISEARCH_API_KEYis optional for local unsecured instances and required for secured deployments. -
MEILISEARCH_DEFAULT_INDEXis used by read-model sync only when the caller does not pass an index and the read model does not define one. -
MEILISEARCH_SEARCH_LIMITis used only when the search call does not specifylimit,page, orhitsPerPage.
Pair v4 integrations should be registered explicitly. An application or optional pair/search-meilisearch package can expose search through a project-owned adapter key:
use Pair\Core\Application;
use Pair\Services\MeilisearchClient;
$app = Application::getInstance();
$app->setAdapter('search', new MeilisearchClient());
$search = $app->adapter('search', MeilisearchClient::class);The framework does not reserve a core adapter key for search providers yet. This keeps Meilisearch optional and leaves room for Elasticsearch, Typesense, database-backed, or hosted search adapters.
Use SearchIndexableReadModel when a read model can describe its own search document.
use Pair\Data\ArraySerializableData;
use Pair\Search\SearchIndexableReadModel;
final readonly class ProductReadModel implements SearchIndexableReadModel {
use ArraySerializableData;
public function __construct(
private int $id,
private string $title,
private string $category,
private bool $active
) {}
public static function searchIndexUid(): string {
return 'products';
}
public static function searchPrimaryKey(): string {
return 'id';
}
public function toArray(): array {
return [
'id' => $this->id,
'title' => $this->title,
'category' => $this->category,
];
}
public function searchDocument(): array {
return [
'id' => $this->id,
'title' => $this->title,
'category' => $this->category,
'is_active' => $this->active,
];
}
}Keep toArray() focused on API output and searchDocument() focused on the index shape. This avoids leaking internal search-only fields into public responses.
createIndex(string $indexUid, ?string $primaryKey = null): arrayupdateSettings(string $indexUid, array $settings): arrayaddOrReplaceDocuments(string $indexUid, array $documents, ?string $primaryKey = null): arrayaddOrUpdateDocuments(string $indexUid, array $documents, ?string $primaryKey = null): arrayindexReadModels(iterable $readModels, ?string $indexUid = null, ?string $primaryKey = null, bool $replace = false): arraydeleteDocument(string $indexUid, string|int $documentId): arraydeleteDocuments(string $indexUid, array $documentIds): arraysearch(string $indexUid, string $query = '', array $options = []): arrayfacetSearch(string $indexUid, string $facetName, ?string $facetQuery = null, array $options = []): arraygetTask(string|int $taskUid): arrayhealth(): arrayversion(): arrayapiKeySet(): bool
Meilisearch indexing methods return task payloads. Store the returned taskUid if the project needs async status tracking.
Meilisearch requires index settings before attributes can be used for filters, facets, sorting, or vector/hybrid search.
use Pair\Services\MeilisearchClient;
$search = new MeilisearchClient();
$search->updateSettings('products', [
'filterable_attributes' => ['tenant_id', 'category', 'is_active'],
'sortable_attributes' => ['price', 'created_at'],
'searchable_attributes' => ['title', 'description'],
'embedders' => [
'default' => [
'source' => 'userProvided',
'dimensions' => 1536,
],
],
]);Snake-case option aliases are accepted and normalized to Meilisearch camelCase fields.
Do not couple writes to a blocking search reindex.
Recommended project flow:
- database write commits first
- enqueue a lightweight sync job containing the record id and operation
- the worker reloads the record, builds the read model, and calls
indexReadModels(...) - the worker stores the returned
taskUidonly if task tracking is useful - delete jobs call
deleteDocument(...)ordeleteDocuments(...)
Example worker body:
use Pair\Data\RecordMapper;
use Pair\Services\MeilisearchClient;
$product = Product::find($job->productId);
$readModel = RecordMapper::map($product, ProductReadModel::class);
$task = (new MeilisearchClient())->indexReadModels([$readModel]);
// Store $task['taskUid'] when the project monitors indexing status.This keeps user-facing requests fast and lets failed indexing jobs be retried independently.
For a public API list endpoint, use Meilisearch as the read side and keep Pair CRUD writes as the source of truth:
use Pair\Http\JsonResponse;
use Pair\Services\MeilisearchClient;
$search = new MeilisearchClient();
$result = $search->search('products', (string)($_GET['q'] ?? ''), [
'filter' => [
'tenant_id = ' . (int)$tenantId,
'is_active = true',
],
'facets' => ['category'],
'page' => max(1, (int)($_GET['page'] ?? 1)),
'hitsPerPage' => 20,
'sort' => ['created_at:desc'],
]);
return new JsonResponse($result);Use tenant filters in every multi-tenant index. Do not expose raw search results if they contain private fields; control the indexed document shape through searchDocument().
$result = $search->facetSearch('products', 'category', (string)($_GET['facetQuery'] ?? ''), [
'filter' => 'is_active = true',
]);Facet search is useful for autocomplete and refinement sidebars. The relevant attributes must be configured as filterable attributes before facet counts are available.
When an index has an embedder configured, pass hybrid and optionally vector to the search payload:
$result = $search->search('products', 'comfortable running shoes', [
'hybrid' => [
'semanticRatio' => 0.5,
'embedder' => 'default',
],
'vector' => $embedding,
'retrieve_vectors' => false,
]);Pair does not generate embeddings inside MeilisearchClient. If the project uses OpenAI or another embedder, generate vectors in the project service or a separate optional package and pass them explicitly.
- Keep
MEILISEARCH_API_KEYin.env, not in Git. - Configure
filterableAttributesbefore relying on tenant, ACL, status, or category filters. - Keep search documents intentionally smaller than database records.
- Avoid indexing secrets, internal notes, or fields not needed by search results.
- Use async jobs for indexing after CRUD writes.
- Treat Meilisearch as a read model, not the source of truth.
See also: Integrations, Configuration file, Env, API, CrudController, OpenAIClient.