Custom Providers
Add support for additional search engines by creating custom providers.
Overview
Providers handle communication with search engines. CelerSearch ships with MeiliSearch support (CelerSearch\Services\MeiliSearch — use it as the reference implementation), but you can add Typesense, Elasticsearch, Algolia, or any other engine.
Architecture
BaseService (abstract)
↓
Your CustomProvider extends BaseService
↓
Implements the abstract methods, returning IndexResponse objects
↓
Register via celersearch_supported_providers filter
The BaseService Contract
Your provider extends CelerSearch\Services\BaseService. The base constructor stores the service configuration as $this->details (a ServiceConfig) and calls your init():
// Provided by BaseService — do not redeclare
public function __construct(ServiceConfig $details); // sets $this->details, calls init()
public function get_provider(): string; // provider slug, e.g. 'typesense'
public function get_settings(BaseIndex $index): array; // build_settings() + filters
public function get_setting_impacts(): array; // optional override, see below
Methods you must implement:
| Method | Return | Description |
|---|---|---|
init() | void | Create the API client. Throw an Exception if config is invalid |
search(BaseIndex, string $query, array $params = []) | IndexResponse | Execute a search |
format_search_params(array $params) | array | Convert normalized params to engine syntax (protected) |
sync_item(BaseIndex, IIndexableObject) | IndexResponse | Upsert one document |
delete_item(BaseIndex, IIndexableObject) | IndexResponse | Delete one document |
count_items(BaseIndex) | IndexResponse | Number of indexed documents |
check_index(BaseIndex) | IndexResponse | Does the index exist? |
touch_index(BaseIndex) | IndexResponse | Create the index if missing |
clear_index(BaseIndex) | IndexResponse | Delete all documents |
delete_index(BaseIndex) | IndexResponse | Delete the index itself |
push_settings(BaseIndex) | IndexResponse | Push build_settings() output to the engine |
build_settings(BaseIndex) | array | Map IndexSettings to the engine's settings payload |
list_documents(BaseIndex, int $offset, int $limit, array $fields = []) | IndexResponse | Paginated document listing |
get_document(BaseIndex, string $id) | IndexResponse | Fetch one document by primary key |
get_index_config(BaseIndex) | IndexResponse | Engine-side effective settings (for the debug UI) |
health_check() | bool | Lightweight reachability check |
wait_for_completion(IndexResponse, int $timeout_ms = 5000) | bool | Block until an async task finishes |
Notes:
- Every operation receives the
BaseIndex— use$index->get_slug()as the engine-side index/collection name. - Methods return
IndexResponseobjects instead of throwing: catch engine exceptions and build anErrorResponse(see below). wait_for_completion()exists because MeiliSearch operations are asynchronous. For synchronous engines (Typesense, Elasticsearch), justreturn true;.
Building Responses
Always construct responses with CelerSearch\Builders\IndexResponseBuilder. The constructor takes the method name, which decides the concrete type build() returns:
- 2xx
response_code()+ method'search'→SearchResponse - 2xx +
'list_documents'→DocumentListResponse - 2xx +
'count_items','get_document', or'get_index_config'→ValueResponse - 2xx + anything else →
SuccessResponse - non-2xx →
ErrorResponse(witherror_code/error_message)
use CelerSearch\Builders\IndexResponseBuilder;
// Success (e.g. inside touch_index)
return (new IndexResponseBuilder(__FUNCTION__))
->response_code(200)
->response_body([])
->build();
// Value (e.g. inside count_items)
return (new IndexResponseBuilder(__FUNCTION__))
->response_code(200)
->value($count)
->build();
// Error
return (new IndexResponseBuilder(__FUNCTION__))
->response_code(500)
->error_message($e->getMessage())
->build();
Passing __FUNCTION__ keeps the method name correct automatically. Async engines additionally call ->task_id() / ->task_object() so wait_for_completion() can poll the task.
Example: Typesense Provider
<?php
namespace MyPlugin\Providers;
use CelerSearch\Abstracts\IndexResponse;
use CelerSearch\Builders\IndexResponseBuilder;
use CelerSearch\Indices\BaseIndex;
use CelerSearch\Interfaces\IIndexableObject;
use CelerSearch\Services\BaseService;
use Typesense\Client;
class TypesenseProvider extends BaseService {
protected Client $client;
public function init(): void {
$config = $this->details->getConfig();
$url = $config->url ?? '';
$apiKey = $config->api_key ?? '';
if (empty($url) || empty($apiKey)) {
throw new \Exception('Typesense URL or API Key is missing.');
}
$this->client = new Client([
'api_key' => $apiKey,
'nodes' => [
[
'host' => parse_url($url, PHP_URL_HOST),
'port' => parse_url($url, PHP_URL_PORT) ?: 443,
'protocol' => parse_url($url, PHP_URL_SCHEME) ?: 'https',
],
],
]);
}
public function health_check(): bool {
try {
$health = $this->client->health->retrieve();
return !empty($health['ok']);
} catch (\Exception $e) {
return false;
}
}
public function search(BaseIndex $index, string $query, array $params = []): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$params = $this->format_search_params($params);
$params['q'] = $query;
$result = $this->client->collections[$index->get_slug()]
->documents->search($params);
$per_page = (int) ($result['request_params']['per_page'] ?? 10);
$total_hits = (int) ($result['found'] ?? 0);
$builder->response_code(200)
->response_body($result)
->hits(array_map(fn($hit) => $hit['document'], $result['hits'] ?? []))
->total_hits($total_hits)
->hits_per_page($per_page)
->current_page((int) ($result['page'] ?? 1))
->total_pages($per_page > 0 ? (int) ceil($total_hits / $per_page) : 1);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
protected function format_search_params(array $params): array {
$formatted = [
'query_by' => '*',
'per_page' => (int) ($params['limit'] ?? $params['hitsPerPage'] ?? 10),
'page' => (int) ($params['page'] ?? 1),
];
// Normalized filters → engine filter string (see "Search Parameters")
if (!empty($params['filters']) && is_array($params['filters'])) {
$formatted['filter_by'] = $this->build_filter_string($params['filters']);
}
// Sort rules arrive as ['field:asc', ...]
if (!empty($params['sort']) && is_array($params['sort'])) {
$formatted['sort_by'] = implode(',', $params['sort']);
}
// Highlighting config
if (!empty($params['highlighting']['enabled'])) {
$formatted['highlight_fields'] = implode(',', $params['highlighting']['fields'] ?? []);
$formatted['highlight_start_tag'] = $params['highlighting']['pre_tag'] ?? '<em>';
$formatted['highlight_end_tag'] = $params['highlighting']['post_tag'] ?? '</em>';
}
return $formatted;
}
public function sync_item(BaseIndex $index, IIndexableObject $item): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$data = $item->get_indexable_data();
$data['id'] = (string) $item->get_id();
$this->client->collections[$index->get_slug()]
->documents->upsert($data);
$builder->response_code(200)->response_body([]);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function delete_item(BaseIndex $index, IIndexableObject $item): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$this->client->collections[$index->get_slug()]
->documents[(string) $item->get_id()]->delete();
$builder->response_code(200)->response_body([]);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function count_items(BaseIndex $index): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$collection = $this->client->collections[$index->get_slug()]->retrieve();
$builder->response_code(200)
->response_body($collection)
->value((int) ($collection['num_documents'] ?? 0));
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function check_index(BaseIndex $index): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$this->client->collections[$index->get_slug()]->retrieve();
$builder->response_code(200)->response_body([]);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function touch_index(BaseIndex $index): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$this->client->collections->create([
'name' => $index->get_slug(),
'fields' => [['name' => '.*', 'type' => 'auto']],
]);
$builder->response_code(200)->response_body([]);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function clear_index(BaseIndex $index): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$this->client->collections[$index->get_slug()]
->documents->delete(['filter_by' => 'id:!=null']);
$builder->response_code(200)->response_body([]);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function delete_index(BaseIndex $index): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$this->client->collections[$index->get_slug()]->delete();
$builder->response_code(200)->response_body([]);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function push_settings(BaseIndex $index): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
// Typesense schemas are largely fixed at collection creation.
// Apply whatever build_settings() produced that CAN be updated.
$settings = $this->build_settings($index);
$this->client->collections[$index->get_slug()]->update($settings);
$builder->response_code(200)->response_body([]);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function build_settings(BaseIndex $index): array {
// Map the engine-agnostic IndexSettings to your engine's payload.
// Note the snake_case getters on IndexSettings.
$s = $index->get_settings();
return [
'default_sorting_field' => $s->get_sortable_attributes()[0] ?? '',
// ... map get_searchable_attributes(), get_filterable_attributes(),
// get_stop_words(), is_typo_enabled(), etc. to your engine
];
}
public function list_documents(BaseIndex $index, int $offset, int $limit, array $fields = []): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$safe_limit = max(1, min(100, $limit));
$page = (int) floor(max(0, $offset) / $safe_limit) + 1;
$result = $this->client->collections[$index->get_slug()]->documents->search([
'q' => '*',
'query_by' => '*',
'per_page' => $safe_limit,
'page' => $page,
'include_fields' => !empty($fields) ? implode(',', $fields) : '*',
]);
$builder->response_code(200)
->response_body([])
->documents(array_map(fn($hit) => $hit['document'], $result['hits'] ?? []))
->total((int) ($result['found'] ?? 0))
->offset($offset)
->limit($safe_limit);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function get_document(BaseIndex $index, string $id): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$doc = $this->client->collections[$index->get_slug()]
->documents[$id]->retrieve();
$builder->response_code(200)->value((array) $doc);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function get_index_config(BaseIndex $index): IndexResponse {
$builder = new IndexResponseBuilder(__FUNCTION__);
try {
$collection = $this->client->collections[$index->get_slug()]->retrieve();
$builder->response_code(200)->value((array) $collection);
} catch (\Exception $e) {
$builder->response_code((int) $e->getCode())
->error_message($e->getMessage());
}
return $builder->build();
}
public function wait_for_completion(IndexResponse $response, int $timeout_ms = 5000): bool {
// Typesense operations are synchronous — nothing to wait for.
return true;
}
private function build_filter_string(array $filters): string {
// Convert CelerSearch's normalized filter format (see below)
// into Typesense filter_by syntax. Left as an exercise.
return '';
}
}
<?php
add_filter('celersearch_supported_providers', function($providers) {
$providers[] = [
'name' => 'Typesense',
'slug' => 'typesense',
'model' => \MyPlugin\Providers\TypesenseProvider::class,
'fields' => [
[
'name' => 'url',
'label' => __('URL', 'my-plugin'),
'type' => 'text',
'rules' => 'required|url',
],
[
'name' => 'api_key',
'label' => __('API Key', 'my-plugin'),
'type' => 'text',
'rules' => 'required',
],
],
];
return $providers;
});
Provider Configuration Fields
The fields array defines the form shown when an admin creates a service of your provider type. Values are saved into the service's config object and are available in init() via $this->details->getConfig()->{name}.
'fields' => [
[
'name' => 'field_key', // Stored in config
'label' => __('Field Label', 'my-plugin'),
'type' => 'text', // text, textarea, dropdown, checkbox
'rules' => 'required|url', // Validation rules
],
]
Supported field types: text, textarea, dropdown (add 'multiple' => true for multi-select, and an 'options' callable returning [['label' => ..., 'value' => ...], ...]), and checkbox.
Search Parameters
CelerSearch passes normalized parameters into search(); your format_search_params() converts them to engine syntax:
// Params arriving from CelerSearch (query string is a separate argument)
$params = [
'limit' => 20, // or 'hitsPerPage' + 'page'
'offset' => 0,
'sort' => ['created_at:desc', 'title:asc'],
'facets' => ['taxonomies.product_cat'],
'highlighting' => [
'enabled' => true,
'fields' => ['title', 'content'],
'pre_tag' => '<em>',
'post_tag' => '</em>',
],
'filters' => [
// Normalized, engine-agnostic filter format:
'relation' => 'AND',
'conditions' => [
['field' => 'status', 'operator' => '=', 'value' => 'publish'],
['field' => 'taxonomies.category.slug', 'operator' => 'IN', 'value' => ['news', 'updates']],
],
],
];
Filter operators to support: =, !=, IN, NOT IN, >, >=, <, <=. Conditions may nest (a condition can itself be a relation + conditions group). See MeiliSearch::format_search_params() and its build_filter_string() for a complete reference implementation.
Settings Mapping
build_settings(BaseIndex $index) returns the engine settings payload as an array. Read the engine-agnostic settings via $index->get_settings() (an IndexSettings object — note the snake_case getters):
$s = $index->get_settings();
$s->get_primary_key(); // 'object_id'
$s->get_searchable_attributes(); // ['title', 'content']
$s->get_filterable_attributes(); // ['category', 'status']
$s->get_sortable_attributes(); // ['created_at', 'title']
$s->get_distinct_attribute(); // 'post_id'
$s->get_stop_words();
$s->get_ranking_rules();
$s->is_typo_enabled();
$s->get_synonyms(); // IndexSynonym[] (->word, ->corrections)
See the Index Settings Reference for the full list.
Required filterable attributes
Always union $index->required_filterable_attributes() (default ['status']) into your filterable attributes when building settings — the plugin's own query paths filter on these, and search silently breaks if the engine rejects them. MeiliSearch::build_settings() shows the pattern, including the celersearch_required_filterable_attributes filter.
Setting Impacts (optional)
Override get_setting_impacts() to tell the admin UI what happens when a setting changes on your engine:
public function get_setting_impacts(): array {
return [
'searchable_attributes' => 'reindex', // engine re-indexes automatically
'filterable_attributes' => 'rebuild', // plugin must re-push documents
];
}
Keys not listed imply the change applies instantly.
Best Practices
Return ErrorResponse, don't throw
Operations are called from observers, cron, and AJAX handlers. Catch engine exceptions and return an ErrorResponse via the builder (non-2xx response_code() + error_message()) — only init() should throw.
Async engines
If your engine queues operations (like MeiliSearch), set ->task_id() / ->task_object() on the builder and implement wait_for_completion() to poll the task. Rebuilds rely on it: page 1 deletes, recreates, and pushes settings, waiting for each task before indexing.