Skip to content

MeilisearchClient

Viames Marino edited this page Apr 22, 2026 · 1 revision

Pair framework: 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

Configuration

MEILISEARCH_HOST="http://127.0.0.1:7700"
MEILISEARCH_API_KEY=
MEILISEARCH_DEFAULT_INDEX=
MEILISEARCH_TIMEOUT=10
MEILISEARCH_CONNECT_TIMEOUT=3
MEILISEARCH_SEARCH_LIMIT=20

Keys:

  • MEILISEARCH_HOST is the Meilisearch server URL.
  • MEILISEARCH_API_KEY is optional for local unsecured instances and required for secured deployments.
  • MEILISEARCH_DEFAULT_INDEX is used by read-model sync only when the caller does not pass an index and the read model does not define one.
  • MEILISEARCH_SEARCH_LIMIT is used only when the search call does not specify limit, page, or hitsPerPage.

Extension path

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.

Search-indexable read models

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.

Main methods

  • createIndex(string $indexUid, ?string $primaryKey = null): array
  • updateSettings(string $indexUid, array $settings): array
  • addOrReplaceDocuments(string $indexUid, array $documents, ?string $primaryKey = null): array
  • addOrUpdateDocuments(string $indexUid, array $documents, ?string $primaryKey = null): array
  • indexReadModels(iterable $readModels, ?string $indexUid = null, ?string $primaryKey = null, bool $replace = false): array
  • deleteDocument(string $indexUid, string|int $documentId): array
  • deleteDocuments(string $indexUid, array $documentIds): array
  • search(string $indexUid, string $query = '', array $options = []): array
  • facetSearch(string $indexUid, string $facetName, ?string $facetQuery = null, array $options = []): array
  • getTask(string|int $taskUid): array
  • health(): array
  • version(): array
  • apiKeySet(): bool

Meilisearch indexing methods return task payloads. Store the returned taskUid if the project needs async status tracking.

Configure filters, facets, sorting, and vectors

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.

Queue-friendly sync recipe

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 taskUid only if task tracking is useful
  • delete jobs call deleteDocument(...) or deleteDocuments(...)

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.

CRUD/API search adapter example

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().

Faceted search

$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.

Hybrid and vector search

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.

Operational notes

  • Keep MEILISEARCH_API_KEY in .env, not in Git.
  • Configure filterableAttributes before 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.

Meilisearch references

See also: Integrations, Configuration file, Env, API, CrudController, OpenAIClient.

Clone this wiki locally