Skip to content

SearchableDataProvider plugins

How to implement a search provider that exposes your module's data to the unified search system (used by the CKEditor integration, autocomplete endpoints, and any future search consumers).

When to use this

  • Your module stores a WoW entity type (items, creatures, quests, etc.) and you want it searchable from the editor modal or other search UIs.
  • You want to provide local-first search with optional Blizzard Search API fallback for entities not yet imported.

The interface

Drupal\wow\SearchableDataProviderInterface

interface SearchableDataProviderInterface extends PluginInspectionInterface {
  public function getSearchLabel(): string;
  public function search(string $query, string $region, int $limit = 10): array;
  public function getCanonicalUrl(int $blizzardId): ?Url;
  public function importEntity(int $blizzardId, string $region): ?Url;
}
Method Returns Purpose
getSearchLabel() string Human-readable label for this category (e.g. "Items", "Creatures"). Shown in search UIs.
search() SearchResult[] Query local storage first, optionally fall back to the Blizzard Search API if fewer than $limit results are found.
getCanonicalUrl() Url\|null Canonical page URL for an entity by Blizzard ID. NULL if the type has no canonical page.
importEntity() Url\|null Sync a remote entity from the Blizzard API, persist it locally, return the canonical URL. NULL if import failed or unsupported.

The attribute

use Drupal\wow\Attribute\SearchableDataProvider;

#[SearchableDataProvider(
  id: 'my_things',
  label: new TranslatableMarkup('My things'),
  weight: 15,
)]
class MyThingSearch extends SearchableDataProviderBase {
  // ...
}

Plugins live in modules/<module>/src/Plugin/SearchableDataProvider/.

Attribute property Purpose
id Plugin ID. Used as the type parameter in search queries.
label Human-readable label for search UI dropdowns.
weight Display order. Lower values appear first. Controls iteration order in multi-provider searches.

The SearchResult value object

Drupal\wow\Search\SearchResult

Every search() call returns an array of these:

new SearchResult(
  blizzardId: 19019,
  name: 'Thunderfury, Blessed Blade of the Windseeker',
  type: 'items',
  isLocal: true,
  iconUrl: 'https://render.worldofwarcraft.com/icons/56/inv_sword_39.jpg',
  description: 'Unique legendary sword',
  quality: 'LEGENDARY',
);
Property Type Notes
blizzardId int The Blizzard API ID.
name string Display name.
type string Plugin ID of the provider that returned this result.
isLocal bool TRUE = from local database. FALSE = from Blizzard Search API (not yet imported).
iconUrl ?string Icon URL, or NULL.
typeLabel string Stamped by the SearchOrchestrator — providers do not need to set this.
description ?string Short description or flavor text.
quality ?string Quality tier (e.g. "RARE", "EPIC"). Primarily for items; NULL for most types.

Local vs remote search pattern

Most providers follow a two-phase search:

  1. Local search — query the Drupal entity storage (CONTAINS on the name field). Fast, returns only imported entities.
  2. Remote fallback — if the local search returned fewer than $limit results AND the data type supports the Blizzard Search API, query the API to fill the gap. Mark these results with isLocal: false.

Types with a full synced catalog (achievements, mounts, pets, toys, titles, quests) only need local search — the entire catalog is already in the database. Types with on-demand import (items, creatures) benefit from the remote fallback.

public function search(string $query, string $region, int $limit = 10): array {
  // Phase 1: local.
  $results = $this->searchLocal($query, $limit);

  // Phase 2: remote fallback if under limit.
  if (count($results) < $limit) {
    $remaining = $limit - count($results);
    $remoteResults = $this->searchRemote($query, $region, $remaining);
    $results = array_merge($results, $remoteResults);
  }

  return $results;
}

importEntity()

Called when a consumer (the CKEditor plugin, for example) selects a remote search result and needs it persisted before linking to it.

  • Fetch the entity from the Blizzard API.
  • Create or update the local entity.
  • Return the canonical URL so the consumer can build the link.
  • Return NULL if import fails or the provider doesn't support remote import.

Local-only providers (achievements, mounts, etc.) return NULL from importEntity() — their catalogs are already fully synced.

The orchestrator

Drupal\wow\Search\SearchOrchestrator (service: wow.search_orchestrator)

Controllers and UI consumers should call the orchestrator rather than using the plugin manager directly. It handles:

  • Multi-provider iteration — queries all enabled providers (or a single one if a type filter is passed).
  • Budget allocation — distributes the total result limit across providers with a per-provider cap to ensure result diversity.
  • Failure isolation — one provider throwing an exception doesn't abort the search. The error is logged and remaining providers continue.
  • Metadata stamping — stamps typeLabel on each SearchResult from the provider's getSearchLabel().
  • Minimum query length — queries shorter than 2 characters return empty immediately.
// Search all providers.
$results = \Drupal::service('wow.search_orchestrator')
  ->search('thunderfury', 'eu', limit: 20);

// Search a single provider.
$results = \Drupal::service('wow.search_orchestrator')
  ->search('thunderfury', 'eu', limit: 10, type: 'items');

// List available provider types (for building a dropdown).
$providers = \Drupal::service('wow.search_orchestrator')
  ->getAvailableProviders();

JSON endpoints

Path Method Parameters Returns
/wow/search GET q (query), limit, type (optional) JSON array of SearchResult objects

The search endpoint always uses the configured default region from wow.settings. There is no region query parameter.

Import

Path Method Returns
/wow/search/import POST JSON with url (canonical) or error

The import endpoint requires a CSRF token and a JSON request body:

POST /wow/search/import
Content-Type: application/json
X-CSRF-Token: {token}

{"type": "items", "id": 19019}

Response on success:

{"url": "/wow/item/19019/thunderfury"}

The CSRF token is obtained from /session/token.

Existing implementations

Plugin ID Module Weight Remote search Import
items wow_item 0 Yes (Blizzard Search API) Yes
creatures wow_creature 0 Yes (Blizzard Search API) Yes
achievements wow_achievement 10 No (full catalog synced) No
quests wow_quest 10 No (full catalog synced) No
mounts wow_mount 20 No (full catalog synced) No
pets wow_pet 25 No (full catalog synced) No
toys wow_toy 30 No (full catalog synced) No
titles wow_title 35 No (full catalog synced) No

See also

  • Editor integration — the CKEditor 5 consumer that drives the search modal
  • Adding submodules — the full submodule scaffold if your search provider accompanies a new entity type
  • Plugin system — how SearchableDataProvider fits alongside DashboardSectionProvider and CharacterDataProvider