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

Text
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():

PHP
// 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:

MethodReturnDescription
init()voidCreate the API client. Throw an Exception if config is invalid
search(BaseIndex, string $query, array $params = [])IndexResponseExecute a search
format_search_params(array $params)arrayConvert normalized params to engine syntax (protected)
sync_item(BaseIndex, IIndexableObject)IndexResponseUpsert one document
delete_item(BaseIndex, IIndexableObject)IndexResponseDelete one document
count_items(BaseIndex)IndexResponseNumber of indexed documents
check_index(BaseIndex)IndexResponseDoes the index exist?
touch_index(BaseIndex)IndexResponseCreate the index if missing
clear_index(BaseIndex)IndexResponseDelete all documents
delete_index(BaseIndex)IndexResponseDelete the index itself
push_settings(BaseIndex)IndexResponsePush build_settings() output to the engine
build_settings(BaseIndex)arrayMap IndexSettings to the engine's settings payload
list_documents(BaseIndex, int $offset, int $limit, array $fields = [])IndexResponsePaginated document listing
get_document(BaseIndex, string $id)IndexResponseFetch one document by primary key
get_index_config(BaseIndex)IndexResponseEngine-side effective settings (for the debug UI)
health_check()boolLightweight reachability check
wait_for_completion(IndexResponse, int $timeout_ms = 5000)boolBlock until an async task finishes

Notes:

  • Every operation receives the BaseIndex — use $index->get_slug() as the engine-side index/collection name.
  • Methods return IndexResponse objects instead of throwing: catch engine exceptions and build an ErrorResponse (see below).
  • wait_for_completion() exists because MeiliSearch operations are asynchronous. For synchronous engines (Typesense, Elasticsearch), just return 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 (with error_code / error_message)
PHP
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

TypesenseProvider.php
PHP
<?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 '';
    }
}
registration.php
PHP
<?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}.

PHP
'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:

PHP
// 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):

PHP
$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:

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

Next Steps