Custom Observers

Observers enable real-time synchronization between WordPress and your search index.

Overview

Observers watch for WordPress events (post saves, deletes, meta changes) and trigger index updates. Each index type needs an observer to stay synchronized.

Architecture

Text
WordPress Event (post_save, delete, etc.)
    ↓
Observer::observe() hooks into events
    ↓
Event handler validates and processes
    ↓
Queue or direct sync to search engine

Creating a Custom Observer

Extend BaseObserver and implement the observe() method:

PHP
<?php
namespace MyPlugin\Observers;

use CelerSearch\DataTransfer\IndexCandidate;
use CelerSearch\Observers\BaseObserver;

class EventObserver extends BaseObserver {

    public function observe(): void {
        // Hook into WordPress events
        add_action('wp_after_insert_post', [$this, 'on_event_saved'], 10, 2);
        add_action('before_delete_post', [$this, 'on_event_deleted']);
        add_action('trashed_post', [$this, 'on_event_trashed']);

        // Watch meta changes
        add_action('updated_post_meta', [$this, 'on_meta_updated'], 10, 4);
    }

    public function on_event_saved(int $post_id, $post = null): void {
        // Skip autosaves
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        $post = $post ?? get_post($post_id);
        if (!$post) {
            return;
        }

        // Check if this index supports this post
        $candidate = new IndexCandidate($post);
        if (!$this->index->is_supported($candidate)) {
            return;
        }

        // Allow filtering
        if (!apply_filters('my_plugin_should_sync_event', true, $post, $this->index)) {
            return;
        }

        // Use queue if available, otherwise sync directly
        if ($this->should_use_queue()) {
            $this->queue_sync('post', $post->ID);
        } else {
            $this->sync_item($post);
        }
    }

    public function on_event_deleted(int $post_id): void {
        $post = get_post($post_id);
        if (!$post) {
            return;
        }

        $candidate = new IndexCandidate($post);
        if (!$this->index->is_supported($candidate)) {
            return;
        }

        if ($this->should_use_queue()) {
            $this->queue_delete('post', $post->ID);
        } else {
            $this->delete_item($post);
        }
    }

    public function on_event_trashed(int $post_id): void {
        // Treat trash as delete
        $this->on_event_deleted($post_id);
    }

    public function on_meta_updated($meta_id, $post_id, $meta_key, $meta_value): void {
        // Only watch specific meta keys
        $watched_keys = ['start_date', 'end_date', 'venue', 'is_featured'];

        if (!in_array($meta_key, $watched_keys, true)) {
            return;
        }

        // Re-sync the post
        $this->on_event_saved($post_id);
    }
}

Registering Your Observer

Use the celersearch_observer_for_index filter:

PHP
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);

BaseObserver Methods

Your observer inherits these methods from BaseObserver:

sync_item($item)

Syncs a raw item (WP_Post, etc.) to the index:

PHP
// Called internally
$this->sync_item($post);

// What it does:
// 1. Wraps item in IndexCandidate
// 2. Checks is_supported() and should_index()
// 3. Calls get_candidate_objects()
// 4. Sends to search engine

delete_item($item)

Deletes an item from the index:

PHP
$this->delete_item($post);

// Handles split records automatically

delete_post_records($post_id)

Deletes all records for a post ID (handles splits):

PHP
$this->delete_post_records($post_id);

// Deletes: 123-0, 123-1, 123-2, etc.

queue_sync($item_type, $item_id)

Schedules a sync via Action Scheduler:

PHP
$this->queue_sync('post', $post->ID);

// Schedules with 5-second debounce

queue_delete($item_type, $item_id)

Schedules a delete via Action Scheduler:

PHP
$this->queue_delete('post', $post->ID);

// Runs immediately (no debounce)

should_use_queue()

Checks if queue system is enabled:

PHP
if ($this->should_use_queue()) {
    $this->queue_sync('post', $id);
} else {
    $this->sync_item($post);
}

Handling Non-Post Content

For custom database tables or external data:

PHP
class CustomTableObserver extends BaseObserver {

    public function observe(): void {
        // Hook into your custom events
        add_action('my_table_row_inserted', [$this, 'on_row_saved']);
        add_action('my_table_row_updated', [$this, 'on_row_saved']);
        add_action('my_table_row_deleted', [$this, 'on_row_deleted']);
    }

    public function on_row_saved($row): void {
        $candidate = new IndexCandidate($row);

        if (!$this->index->is_supported($candidate)) {
            return;
        }

        if ($this->should_use_queue()) {
            $this->queue_sync('custom_row', $row->id);
        } else {
            $this->sync_item($row);
        }
    }

    public function on_row_deleted($row_id): void {
        // For queue, we need the full object
        // For direct delete, we can use the ID

        if ($this->should_use_queue()) {
            $this->queue_delete('custom_row', $row_id);
        } else {
            // Create minimal object for deletion
            $row = (object) ['id' => $row_id];
            $this->delete_item($row);
        }
    }
}

Queue Handler Registration

For non-post items, you need to handle the queue processing:

PHP
// In your index class, implement these for queue support:
public function get_item_for_sync(string $item_type, $item_id) {
    if ($item_type === 'custom_row') {
        global $wpdb;
        return $wpdb->get_row(
            $wpdb->prepare("SELECT * FROM my_table WHERE id = %d", $item_id)
        );
    }

    return null;
}

public function delete_from_index($item_type, $item_id): void {
    if ($item_type === 'custom_row') {
        $this->get_service()->delete_item(
            $this->get_index_uid(),
            (string) $item_id
        );
    }
}

Best Practices

Check Support First

Always verify is_supported() before processing to avoid unnecessary work:

PHP
$candidate = new IndexCandidate($item);
if (!$this->index->is_supported($candidate)) {
    return;
}

Use Queue When Available

The queue system provides debouncing and reliability:

PHP
if ($this->should_use_queue()) {
    $this->queue_sync('post', $id);
} else {
    $this->sync_item($item);
}

Skip Autosaves

Always skip WordPress autosaves:

PHP
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
    return;
}

Next Steps