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
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.
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.
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?
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?
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.
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.
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.
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.
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:
<?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)];
}
}
<?php
namespace MyPlugin\DataTransfer;
use CelerSearch\Abstracts\IndexableObject;
class IndexableEvent extends IndexableObject {
// Inherits everything from IndexableObject
// No additional code needed for simple cases
}
<?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:
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:
$data = apply_filters('my_index_record_data', $data, $post);
Next Steps
- Create custom observers for real-time sync
- Add custom providers for other search engines
- Customize with hooks