Appearance
Multi-site configuration
Configure Search with Elastic for multi-site Craft installations. This guide covers site-specific indexing strategies, cross-site search, and managing content in multiple languages and locales.
Multi-site indexing strategies
The plugin automatically creates separate indexes for each site using the pattern {prefix}{indexName}_{siteId}
.
Separate indexes per site (default)
Each site gets its own set of indexes:
php
return [
'indexPrefix' => 'craft-',
'fallbackIndexName' => 'elements',
// This creates:
// Site 1: craft-elements_1
// Site 2: craft-elements_2
// Site 3: craft-elements_3
];
Site-specific index naming
Customize index names for different sites:
php
return [
'indexPrefix' => match(Craft::$app->sites->getCurrentSite()->handle) {
'english' => 'en-',
'french' => 'fr-',
'spanish' => 'es-',
default => 'craft-'
},
'fallbackIndexName' => 'content',
];
// Results in: en-content_1, fr-content_2, es-content_3
php
return [
'indexPrefix' => match(Craft::$app->sites->getCurrentSite()->handle) {
'brandA' => 'branda-',
'brandB' => 'brandb-',
'corporate' => 'corp-',
default => 'site-'
},
'fallbackIndexName' => 'elements',
];
// Results in: branda-elements_1, brandb-elements_2, corp-elements_3
php
return [
'indexPrefix' => match(Craft::$app->sites->getCurrentSite()->handle) {
'us' => 'us-',
'uk' => 'uk-',
'au' => 'au-',
default => 'global-'
},
'fallbackIndexName' => 'content',
];
// Results in: us-content_1, uk-content_2, au-content_3
Site-specific configuration
Configure different settings for each site based on their needs.
Language-specific settings
php
return [
// Base configuration for all sites
'indexPrefix' => 'craft-',
'fallbackIndexName' => 'content',
'enableFrontendFetching' => true,
// Language-specific customizations
'*' => [
'highlight' => function() {
$site = Craft::$app->sites->getCurrentSite();
return match($site->language) {
'en-US', 'en-GB' => [
'pre_tags' => '<mark>',
'post_tags' => '</mark>',
],
'fr-FR' => [
'pre_tags' => '<em class="highlight">',
'post_tags' => '</em>',
],
'es-ES' => [
'pre_tags' => '<span class="resaltado">',
'post_tags' => '</span>',
],
default => [
'pre_tags' => '',
'post_tags' => '',
]
};
},
'assetKinds' => function() {
$site = Craft::$app->sites->getCurrentSite();
return match($site->language) {
'en-US' => ['pdf', 'text', 'html', 'word', 'excel'],
'fr-FR' => ['pdf', 'text', 'html'], // Lighter indexing for French site
'es-ES' => ['pdf', 'text'], // Minimal for Spanish site
default => ['pdf']
};
},
],
];
php
return [
'indexPrefix' => 'craft-',
'fallbackIndexName' => 'content',
// Main English site - full indexing
'english' => [
'indexableEntryStatuses' => ['pending', 'live'],
'assetKinds' => ['pdf', 'text', 'html', 'json', 'xml', 'word', 'excel'],
'enableFrontendFetching' => true,
'excludedEntryTypes' => [],
],
// French site - selective indexing
'french' => [
'indexableEntryStatuses' => ['live'], // Only live content
'assetKinds' => ['pdf', 'text', 'html'],
'enableFrontendFetching' => true,
'excludedEntryTypes' => ['newsArticles'], // Exclude news from French site
],
// Mobile site - minimal indexing
'mobile' => [
'indexableEntryStatuses' => ['live'],
'assetKinds' => ['pdf'], // Only PDFs
'enableFrontendFetching' => false, // Metadata only
'excludedEntryTypes' => ['longFormContent', 'detailedPages'],
],
];
Regional content filtering
Filter content based on regional requirements:
php
return [
// Base configuration
'indexPrefix' => 'regional-',
'fallbackIndexName' => 'content',
// US site - include all content
'us' => [
'excludedEntryTypes' => [],
'excludedCategoryGroups' => [],
],
// EU site - exclude US-specific content
'eu' => [
'excludedEntryTypes' => ['usOnlyProducts', 'usLegalPages'],
'excludedCategoryGroups' => ['usRegulations'],
'excludedProductTypes' => ['usOnlyItems'],
],
// Asia Pacific site - different content focus
'apac' => [
'excludedEntryTypes' => ['usOnlyProducts', 'euOnlyContent'],
'excludedCategoryGroups' => ['westernRegulations'],
'assetKinds' => ['pdf', 'text'], // Lighter asset indexing
],
];
Cross-site search configuration
Enable searching across multiple sites with unified results.
Global search setup
php
// Create a global search service
class GlobalSearchService
{
public function searchAllSites(string $query): array
{
$results = [];
$sites = Craft::$app->sites->getAllSites();
foreach ($sites as $site) {
$searchService = \pennebaker\searchwithelastic\SearchWithElastic::getInstance()->service;
// Switch to site context
Craft::$app->sites->setCurrentSite($site);
// Perform search for this site
$siteResults = $searchService->search($query);
// Add site information to results
foreach ($siteResults as &$result) {
$result['siteId'] = $site->id;
$result['siteHandle'] = $site->handle;
$result['siteName'] = $site->name;
$result['siteLanguage'] = $site->language;
}
$results = array_merge($results, $siteResults);
}
// Sort combined results by relevance score
usort($results, function($a, $b) {
return $b['score'] <=> $a['score'];
});
return $results;
}
}
Cross-site result filtering
Filter and merge results from multiple sites:
php
'resultFormatterCallback' => function (array $formattedResult, $elasticsearchResult) {
$site = Craft::$app->sites->getSiteById($formattedResult['siteId']);
// Add site-specific metadata
$formattedResult['siteInfo'] = [
'id' => $site->id,
'handle' => $site->handle,
'name' => $site->name,
'language' => $site->language,
'baseUrl' => $site->baseUrl,
];
// Add language-specific formatting
if ($site->language === 'fr-FR') {
$formattedResult['dateFormat'] = 'd/m/Y';
} elseif ($site->language === 'en-US') {
$formattedResult['dateFormat'] = 'm/d/Y';
}
// Add cross-site relevance scoring
$formattedResult['crossSiteScore'] = $elasticsearchResult['_score'] *
($site->primary ? 1.2 : 1.0); // Boost primary site results
return $formattedResult;
},
Language and localization
Configure the plugin for different languages and locales.
Language-specific content processing
php
'contentExtractorCallback' => function (string $htmlContent) {
$site = Craft::$app->sites->getCurrentSite();
// Language-specific content processing
switch ($site->language) {
case 'en-US':
case 'en-GB':
// English: Remove stop words and normalize
$content = strip_tags($htmlContent);
$stopWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by'];
$words = explode(' ', strtolower($content));
$words = array_diff($words, $stopWords);
return implode(' ', $words);
case 'fr-FR':
// French: Remove French stop words
$content = strip_tags($htmlContent);
$stopWords = ['le', 'de', 'et', 'à', 'un', 'il', 'être', 'et', 'en', 'avoir', 'que', 'pour'];
$words = explode(' ', strtolower($content));
$words = array_diff($words, $stopWords);
return implode(' ', $words);
case 'es-ES':
// Spanish: Remove Spanish stop words
$content = strip_tags($htmlContent);
$stopWords = ['el', 'la', 'de', 'que', 'y', 'a', 'en', 'un', 'ser', 'se', 'no', 'te', 'lo'];
$words = explode(' ', strtolower($content));
$words = array_diff($words, $stopWords);
return implode(' ', $words);
default:
// Default: Simple tag stripping
return strip_tags($htmlContent);
}
},
php
'extraFields' => [
'formattedDate' => [
'mapping' => ['type' => 'keyword'],
'value' => function (\craft\base\ElementInterface $element) {
$site = Craft::$app->sites->getCurrentSite();
$date = $element->postDate ?? $element->dateCreated;
return match($site->language) {
'en-US' => $date->format('m/d/Y'),
'en-GB' => $date->format('d/m/Y'),
'fr-FR' => $date->format('d/m/Y'),
'de-DE' => $date->format('d.m.Y'),
default => $date->format('Y-m-d')
};
}
],
],
Multilingual field mapping
Index content in multiple languages:
php
'extraFields' => [
// Multi-language title fields
'titleEn' => [
'mapping' => ['type' => 'text', 'analyzer' => 'english'],
'value' => function (\craft\base\ElementInterface $element) {
return $element->title ?? '';
}
],
'titleFr' => [
'mapping' => ['type' => 'text', 'analyzer' => 'french'],
'value' => function (\craft\base\ElementInterface $element) {
// Get French version of the element
$frenchElement = Craft::$app->elements->getElementById($element->id, null, 2); // Site ID 2 = French
return $frenchElement->title ?? '';
}
],
// Language indicator
'language' => [
'mapping' => ['type' => 'keyword'],
'value' => function (\craft\base\ElementInterface $element) {
return Craft::$app->sites->getSiteById($element->siteId)->language;
}
],
// Multilingual categories
'categoriesAllLanguages' => [
'mapping' => ['type' => 'text'],
'value' => function (\craft\base\ElementInterface $element) {
$categoryTitles = [];
// Get categories for all sites
foreach (Craft::$app->sites->getAllSites() as $site) {
$siteElement = Craft::$app->elements->getElementById($element->id, null, $site->id);
if ($siteElement && $siteElement->categories) {
foreach ($siteElement->categories->all() as $category) {
$categoryTitles[] = $category->title;
}
}
}
return implode(' ', array_unique($categoryTitles));
}
],
],
Performance optimization for multi-site
Optimize indexing and search performance across multiple sites.
Selective site indexing
php
return [
// Enable/disable indexing per site
'enabledSites' => function() {
$enabledSites = [];
foreach (Craft::$app->sites->getAllSites() as $site) {
// Skip staging/development sites in production
if (App::env('CRAFT_ENVIRONMENT') === 'production' &&
str_contains($site->handle, 'staging')) {
continue;
}
$enabledSites[] = $site->id;
}
return $enabledSites;
},
// Different indexing schedules per site
'indexingPriority' => [
1 => 'high', // Primary site
2 => 'medium', // French site
3 => 'low', // Mobile site
],
];
Batch processing optimization
php
// Optimize reindexing for multiple sites
class MultiSiteIndexingService
{
public function reindexAllSites(): void
{
$sites = Craft::$app->sites->getAllSites();
foreach ($sites as $site) {
// Process each site in a separate queue job
Craft::$app->queue->push(new ReindexSiteJob([
'siteId' => $site->id,
'priority' => $this->getSitePriority($site->id),
'batchSize' => $this->getSiteBatchSize($site->id),
]));
}
}
private function getSitePriority(int $siteId): int
{
// Primary site gets highest priority
return $siteId === 1 ? 1024 : 512;
}
private function getSiteBatchSize(int $siteId): int
{
// Smaller batches for secondary sites
return $siteId === 1 ? 100 : 50;
}
}
Multi-site maintenance
Monitoring multiple indexes
php
// Health check for all site indexes
class MultiSiteHealthCheck
{
public function checkAllSites(): array
{
$results = [];
$elasticsearch = \pennebaker\searchwithelastic\SearchWithElastic::getInstance()->service;
foreach (Craft::$app->sites->getAllSites() as $site) {
try {
Craft::$app->sites->setCurrentSite($site);
$indexName = $this->getIndexName($site);
$health = $elasticsearch->indices()->stats(['index' => $indexName]);
$results[$site->handle] = [
'status' => 'healthy',
'documentCount' => $health['indices'][$indexName]['total']['docs']['count'] ?? 0,
'indexSize' => $health['indices'][$indexName]['total']['store']['size_in_bytes'] ?? 0,
];
} catch (Exception $e) {
$results[$site->handle] = [
'status' => 'error',
'error' => $e->getMessage(),
];
}
}
return $results;
}
private function getIndexName($site): string
{
$settings = \pennebaker\searchwithelastic\SearchWithElastic::getInstance()->getSettings();
return $settings->indexPrefix . $settings->fallbackIndexName . '_' . $site->id;
}
}
Site-specific backup and recovery
bash
#!/bin/bash
# Backup all site indexes
SITES=("1" "2" "3")
DATE=$(date +%Y%m%d_%H%M%S)
for SITE_ID in "${SITES[@]}"; do
echo "Backing up site $SITE_ID..."
curl -X PUT "localhost:9200/_snapshot/backup_repo/site_${SITE_ID}_${DATE}" \
-H 'Content-Type: application/json' \
-d"{
\"indices\": \"craft-*_${SITE_ID}\",
\"ignore_unavailable\": true,
\"include_global_state\": false
}"
done
Cross-site consistency checks
php
// Verify content consistency across sites
class CrossSiteConsistencyChecker
{
public function checkElementConsistency(int $elementId): array
{
$results = [];
$sites = Craft::$app->sites->getAllSites();
foreach ($sites as $site) {
$element = Craft::$app->elements->getElementById($elementId, null, $site->id);
if ($element) {
$results[$site->handle] = [
'exists' => true,
'status' => $element->status,
'lastUpdated' => $element->dateUpdated->format('Y-m-d H:i:s'),
'indexed' => $this->isElementIndexed($element),
];
} else {
$results[$site->handle] = [
'exists' => false,
'indexed' => false,
];
}
}
return $results;
}
private function isElementIndexed($element): bool
{
$elasticsearch = \pennebaker\searchwithelastic\SearchWithElastic::getInstance()->service;
try {
$result = $elasticsearch->get([
'index' => $this->getIndexName($element->siteId),
'id' => $element->id,
]);
return isset($result['found']) && $result['found'];
} catch (Exception $e) {
return false;
}
}
}
Template usage for multi-site
Search across sites in your templates:
twig
{# Search current site only #}
{% set results = craft.elasticsearch.search('search term') %}
{% for result in results %}
<div class="result">
<h3>{{ result.title }}</h3>
<p>{{ result.summary }}</p>
<small>Site: {{ siteName }}</small>
</div>
{% endfor %}
twig
{# Search across all sites #}
{% set allResults = [] %}
{% for site in craft.app.sites.allSites %}
{% set oldSite = craft.app.sites.currentSite %}
{% do craft.app.sites.setCurrentSite(site) %}
{% set siteResults = craft.elasticsearch.search('search term') %}
{% for result in siteResults %}
{% set result = result | merge({
siteId: site.id,
siteName: site.name,
siteHandle: site.handle
}) %}
{% set allResults = allResults | merge([result]) %}
{% endfor %}
{% do craft.app.sites.setCurrentSite(oldSite) %}
{% endfor %}
{# Sort by relevance score #}
{% set sortedResults = allResults | sort((a, b) => b.score <=> a.score) %}
{% for result in sortedResults %}
<div class="result">
<h3>{{ result.title }}</h3>
<p>{{ result.summary }}</p>
<small>
Site: {{ result.siteName }} ({{ result.siteHandle }})
| Score: {{ result.score | round(2) }}
</small>
</div>
{% endfor %}
twig
{# Search only sites with specific language #}
{% set targetLanguage = 'en-US' %}
{% set results = [] %}
{% for site in craft.app.sites.allSites %}
{% if site.language == targetLanguage %}
{% set oldSite = craft.app.sites.currentSite %}
{% do craft.app.sites.setCurrentSite(site) %}
{% set siteResults = craft.elasticsearch.search('search term') %}
{% set results = results | merge(siteResults) %}
{% do craft.app.sites.setCurrentSite(oldSite) %}
{% endif %}
{% endfor %}
{% for result in results %}
<div class="result">
<h3>{{ result.title }}</h3>
<p>{{ result.summary }}</p>
</div>
{% endfor %}
This completes the comprehensive multi-site configuration guide, covering all aspects of managing Elasticsearch indexes across multiple Craft CMS sites.