Custom Indices

Create your own index types to make any content searchable with CelerSearch.

Overview

Custom indices extend BaseIndex and implement 8 abstract methods that define:

  • What content to index
  • How to structure the data
  • What fields are searchable/filterable/sortable

Architecture

Text
BaseIndex (abstract)
    ↓
Your CustomIndex extends BaseIndex
    ↓
Implements 8 abstract methods
    ↓
Register via celersearch_supported_indices filter

The 8 Abstract Methods

1. init(): void

Initialize your index with configuration from the database.

PHP
protected function init(): void {
    $config = $this->details->getConfig();

    // Config is stored as JSON, may need conversion
    $this->custom_setting = $config->custom_setting ?? 'default';

    // Handle object-to-array conversion
    if (isset($config->items) && is_object($config->items)) {
        $this->items = (array) $config->items;
    }
}

2. get_settings(): IndexSettings

Define searchable, filterable, and sortable attributes.

PHP
public function get_settings(): IndexSettings {
    return (new IndexSettingsBuilder)
        ->from_stored($this->get_stored_settings())
        ->primary_key('object_id')           // Required, must be unique
        ->distinct_attribute('item_id')       // For deduplication
        ->searchable_attributes([
            'title',                          // Highest priority
            'content',
            'tags',
        ])
        ->filterable_attributes([
            'category',
            'status',
            'author.name',                    // Nested fields supported
        ])
        ->sortable_attributes([
            'created_at',
            'title',
        ])
        ->build();
}

3. is_supported(IndexCandidate $item): bool

Fast type check - is this item type handled by this index?

PHP
public function is_supported(IndexCandidate $item): bool {
    // Always check $item is not null
    if (!$item) {
        return false;
    }

    // Check the wrapped item's type
    return is_a($item->item, \WP_Post::class)
        && in_array($item->item->post_type, $this->supported_types, true);
}

4. should_index(IndexCandidate $item): bool

Detailed check - should this specific item be indexed?

PHP
public function should_index(IndexCandidate $item): bool {
    if (!$this->is_supported($item)) {
        return false;
    }

    $post = $item->item;

    // Check status
    $allowed_statuses = apply_filters(
        'my_index_allowed_statuses',
        ['publish', 'private']
    );

    if (!in_array($post->post_status, $allowed_statuses, true)) {
        return false;
    }

    // Check custom conditions
    if (get_post_meta($post->ID, 'exclude_from_search', true)) {
        return false;
    }

    return true;
}

5. get_candidates(int $page, int $batch_size): array

Retrieve paginated items for indexing.

PHP
public function get_candidates(int $page, int $batch_size): array {
    $args = [
        'post_type'              => $this->supported_types,
        'posts_per_page'         => $batch_size,
        'paged'                  => $page,  // 1-indexed
        'post_status'            => 'any',
        'orderby'                => 'ID',
        'order'                  => 'ASC',
        // Performance optimizations
        'suppress_filters'       => true,
        'cache_results'          => false,
        'lazy_load_term_meta'    => false,
        'update_post_term_cache' => false,
    ];

    $posts = get_posts($args);

    // MUST wrap in IndexCandidate
    return array_map(
        fn(\WP_Post $post) => new IndexCandidate($post),
        $posts
    );
}

6. get_candidates_by_ids(array $ids): array

Retrieve specific items by ID.

PHP
public function get_candidates_by_ids(array $ids): array {
    if (empty($ids)) {
        return [];
    }

    $args = [
        'post_type'              => $this->supported_types,
        'posts_per_page'         => count($ids),
        'post__in'               => $ids,
        'post_status'            => 'any',
        'orderby'                => 'post__in',
        'suppress_filters'       => true,
        'cache_results'          => false,
    ];

    return array_map(
        fn(\WP_Post $post) => new IndexCandidate($post),
        get_posts($args)
    );
}

7. get_candidate_count(): int

Total count for pagination calculations.

PHP
public function get_candidate_count(): int {
    $query = new \WP_Query([
        'post_type'       => $this->supported_types,
        'post_status'     => 'any',
        'suppress_filters' => true,
        'cache_results'   => false,
    ]);

    return (int) $query->found_posts;
}

8. get_candidate_objects(IndexCandidate $item): array

Transform an item into searchable records.

PHP
public function get_candidate_objects(IndexCandidate $item): array {
    $post = $item->item;  // Unwrap the IndexCandidate

    $data = [
        'object_id'   => (string) $post->ID,  // Primary key
        'item_id'     => (int) $post->ID,     // For distinct
        'title'       => $post->post_title,
        'content'     => wp_strip_all_tags($post->post_content),
        'status'      => $post->post_status,
        'created_at'  => strtotime($post->post_date),
        'permalink'   => get_permalink($post),
        'author'      => [
            'id'   => $post->post_author,
            'name' => get_the_author_meta('display_name', $post->post_author),
        ],
    ];

    // Return array of IIndexableObject
    return [
        new IndexableCustomItem((string) $post->ID, $data)
    ];
}

Complete Example

Here's a full custom index implementation:

CustomEventsIndex.php
PHP
<?php
namespace MyPlugin\Indices;

use CelerSearch\Builders\IndexSettingsBuilder;
use CelerSearch\DataTransfer\IndexCandidate;
use CelerSearch\DataTransfer\IndexSettings;
use CelerSearch\Indices\BaseIndex;
use MyPlugin\DataTransfer\IndexableEvent;

class CustomEventsIndex extends BaseIndex {

    protected array $event_types = [];

    protected function init(): void {
        $config = $this->details->getConfig();
        $this->event_types = isset($config->event_types)
            ? (array) $config->event_types
            : ['event'];
    }

    public function get_settings(): IndexSettings {
        $settings = (new IndexSettingsBuilder)
            ->from_stored($this->get_stored_settings())
            ->primary_key('object_id')
            ->distinct_attribute('event_id')
            ->searchable_attributes([
                'title',
                'description',
                'venue',
                'organizer',
            ])
            ->filterable_attributes([
                'event_type',
                'venue',
                'start_date',
                'is_featured',
            ])
            ->sortable_attributes([
                'start_date',
                'title',
            ]);

        return apply_filters(
            'my_events_index_settings',
            $settings,
            $this
        )->build();
    }

    public function is_supported(IndexCandidate $item): bool {
        return $item
            && is_a($item->item, \WP_Post::class)
            && in_array($item->item->post_type, $this->event_types, true);
    }

    public function should_index(IndexCandidate $item): bool {
        if (!$this->is_supported($item)) {
            return false;
        }

        return in_array(
            $item->item->post_status,
            ['publish', 'private'],
            true
        );
    }

    public function get_candidates(int $page, int $batch_size): array {
        return array_map(
            fn($post) => new IndexCandidate($post),
            get_posts([
                'post_type'        => $this->event_types,
                'posts_per_page'   => $batch_size,
                'paged'            => $page,
                'post_status'      => 'any',
                'orderby'          => 'ID',
                'order'            => 'ASC',
                'suppress_filters' => true,
                'cache_results'    => false,
            ])
        );
    }

    public function get_candidates_by_ids(array $ids): array {
        if (empty($ids)) return [];

        return array_map(
            fn($post) => new IndexCandidate($post),
            get_posts([
                'post_type'        => $this->event_types,
                'posts_per_page'   => count($ids),
                'post__in'         => $ids,
                'post_status'      => 'any',
                'suppress_filters' => true,
                'cache_results'    => false,
            ])
        );
    }

    public function get_candidate_count(): int {
        return (int) (new \WP_Query([
            'post_type'        => $this->event_types,
            'post_status'      => 'any',
            'suppress_filters' => true,
            'cache_results'    => false,
        ]))->found_posts;
    }

    public function get_candidate_objects(IndexCandidate $item): array {
        $post = $item->item;

        $data = [
            'object_id'   => (string) $post->ID,
            'event_id'    => (int) $post->ID,
            'title'       => $post->post_title,
            'description' => wp_strip_all_tags($post->post_content),
            'venue'       => get_post_meta($post->ID, 'venue', true),
            'start_date'  => (int) get_post_meta($post->ID, 'start_date', true),
            'end_date'    => (int) get_post_meta($post->ID, 'end_date', true),
            'organizer'   => get_post_meta($post->ID, 'organizer', true),
            'event_type'  => get_post_meta($post->ID, 'event_type', true),
            'is_featured' => (bool) get_post_meta($post->ID, 'is_featured', true),
            'permalink'   => get_permalink($post),
        ];

        return [new IndexableEvent((string) $post->ID, $data)];
    }
}
IndexableEvent.php
PHP
<?php
namespace MyPlugin\DataTransfer;

use CelerSearch\Abstracts\IndexableObject;

class IndexableEvent extends IndexableObject {
    // Inherits everything from IndexableObject
    // No additional code needed for simple cases
}
registration.php
PHP
<?php
// Register the index type
add_filter('celersearch_supported_indices', function($indices) {
    $indices[] = [
        // `name` and each field `label` must be CALLABLES returning the
        // translated string — calling __() at registration time fires
        // before init and triggers WordPress's "translation loading
        // triggered too early" notice.
        'name'   => function () { return __('Events Index', 'my-plugin'); },
        'type'   => 'events',
        'model'  => \MyPlugin\Indices\CustomEventsIndex::class,
        'fields' => [
            [
                'label'    => function () { return __('Event Types', 'my-plugin'); },
                'name'     => 'event_types',
                // Multi-select is expressed as a dropdown with `multiple`.
                // Valid types: text, textarea, dropdown, checkbox.
                'type'     => 'dropdown',
                'rules'    => 'required',
                'multiple' => true,
                'options'  => function() {
                    return [
                        ['label' => 'Event', 'value' => 'event'],
                        ['label' => 'Webinar', 'value' => 'webinar'],
                        ['label' => 'Conference', 'value' => 'conference'],
                    ];
                },
            ],
        ],
    ];

    return $indices;
});

// Register custom observer (see Custom Observers page)
add_filter('celersearch_observer_for_index', function($observer, $index) {
    if ($index instanceof \MyPlugin\Indices\CustomEventsIndex) {
        return new \MyPlugin\Observers\EventObserver($index);
    }
    return $observer;
}, 10, 2);

Content Splitting

For long content, you can create multiple records per item:

Text
public function get_candidate_objects(IndexCandidate $item): array {
    $post = $item->item;
    $content = wp_strip_all_tags($post->post_content);

    // Split into chunks of ~500 words
    $chunks = str_split($content, 3000);

    $records = [];
    foreach ($chunks as $index => $chunk) {
        $object_id = $post->ID . '-' . $index;

        $records[] = new IndexableEvent($object_id, [
            'object_id'    => $object_id,
            'event_id'     => (int) $post->ID,  // For distinct
            'record_index' => $index,
            'title'        => $post->post_title,
            'content'      => $chunk,
            // ... other fields
        ]);
    }

    return $records;
}

Tips & Best Practices

Performance

Use suppress_filters => true and cache_results => false in WP_Query for indexing operations to avoid interference and memory issues.

Primary Key

Every record must have a unique object_id. For split content, use format like {post_id}-{chunk_index}.

Filters

Always apply filters to allow customization:

Text
$data = apply_filters('my_index_record_data', $data, $post);

Next Steps