Full-Text-Search integrieren
Datenbankbasierte Suche mit LIKE-Queries skaliert nicht — bei mehr als 100.000 Datensätzen wird sie langsam, bei Tippfehlern findet sie nichts, und Relevanz-Ranking ist unmöglich. Elasticsearch löst alle drei Probleme: Millisekunden-Antwortzeiten, Fuzzy-Matching und konfigurierbare Relevanz-Scores — integriert in Symfony über ein klares Indexierungs- und Abfrage-Protokoll.
Inhaltsverzeichnis
- 1. Warum Elasticsearch statt SQL LIKE für Full-Text-Search?
- 2. Architektur: Elasticsearch neben Doctrine betreiben
- 3. Index-Mapping in PHP definieren
- 4. Doctrine-Entities automatisch indexieren
- 5. Multi-Match und Bool-Query für Full-Text-Search
- 6. Facetten und Aggregationen für Filter-Navigation
- 7. Autocomplete mit Edge-N-Gram-Analyzer
- 8. Asynchrone Indexierung mit Symfony Messenger
- 9. Elasticsearch vs. SQL LIKE im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Elasticsearch statt SQL LIKE für Full-Text-Search?
Die häufigste Implementierung von Suche in Symfony-Projekten ist eine Doctrine-Query mit LIKE '%suchbegriff%'. Das funktioniert bei kleinen Datensätzen, hat aber fundamentale Einschränkungen: Erstens ist LIKE '%...' mit führendem Wildcard nicht indexierbar — bei 500.000 Produkten scannt MySQL die gesamte Tabelle. Zweitens gibt es kein Relevanz-Ranking: Alle Treffer sind gleich gewichtet, obwohl ein Produkt, das den Suchbegriff im Namen hat, relevanter ist als eines, das ihn nur in der Beschreibung erwähnt. Drittens fehlt Fuzzy-Matching: "Labtop" findet kein "Laptop". Elasticsearch löst alle drei Probleme mit dem inversen Index, konfigurierbaren Analyzern und dem TF-IDF-Scoring-Algorithmus.
Die Integration von Elasticsearch in Symfony folgt einem klaren Muster: Doctrine ist die Source of Truth für alle Daten, Elasticsearch ist ein Suchindex, der aus Doctrine-Entities befüllt wird. Änderungen an Entities werden nach dem Persistieren asynchron an Elasticsearch propagiert — entweder über Doctrine Event Listener oder über Symfony Messenger. Die Suche selbst läuft direkt gegen Elasticsearch, die Ergebnis-IDs werden dann genutzt, um die vollständigen Entities aus Doctrine zu laden. Dieses Muster hält die Datenbank als autoritäre Quelle, nutzt aber die Suchstärken von Elasticsearch vollständig aus.
2. Architektur: Elasticsearch neben Doctrine betreiben
Die Architektur von Elasticsearch in Symfony-Projekten ist eine Lese/Schreib-Trennung auf Infrastrukturebene: Alle Schreiboperationen gehen an Doctrine, alle Suchanfragen gehen an Elasticsearch. Diese Trennung folgt dem CQRS-Prinzip: der Suchindex ist eine spezialisierte Lesesicht auf die Daten, optimiert für Volltextsuche und Relevanz-Ranking. Doctrine-Entities werden durch Transformer-Klassen in Elasticsearch-Dokumente konvertiert, die den Index befüllen. Das Dokument enthält nur die Felder, die für die Suche und Ergebnis-Anzeige relevant sind — keine internen Datenbankfelder, keine sensiblen Daten.
Die FOSElasticaBundle-Library vereinfacht die Integration erheblich: Sie verwaltet Index-Erstellung und -Konfiguration, automatische Synchronisation beim Persistieren von Doctrine-Entities, Paginierung der Suchergebnisse und das Mapping zwischen Elasticsearch-Dokumenten und Symfony-Objekten. Alternativ kann man den offiziellen Elasticsearch PHP-Client direkt einsetzen und alle Aspekte selbst implementieren — das gibt mehr Kontrolle über das Query-Building, erfordert aber mehr Boilerplate für Synchronisation und Index-Verwaltung. Für neue Projekte ist FOSElasticaBundle der schnellere Einstieg, für komplexe Sucharchitekturen lohnt sich oft der direkte Client.
<?php
// Elasticsearch PHP client — direct usage without FOSElasticaBundle
// composer require elastic/elasticsearch-php
declare(strict_types=1);
namespace App\Search\Infrastructure;
use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\ClientBuilder;
/**
* Factory for creating a configured Elasticsearch client.
*/
final class ElasticsearchClientFactory
{
/**
* Build the Elasticsearch client from environment configuration.
*/
public static function create(string $host, ?string $apiKey = null): Client
{
$builder = ClientBuilder::create()
->setHosts([$host]);
// Use API key authentication for Elasticsearch 8.x (recommended over basic auth)
if ($apiKey !== null) {
$builder->setApiKey($apiKey);
}
return $builder->build();
}
}
// Define index mapping — controls how fields are analyzed and stored
// This would typically live in a JSON file or a PHP array constant
$productIndexMapping = [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 1,
'analysis' => [
'analyzer' => [
// German analyzer for product descriptions
'german_search' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase', 'german_stop', 'german_stemmer'],
],
// Edge n-gram analyzer for autocomplete
'autocomplete' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => ['lowercase', 'autocomplete_filter'],
],
],
'filter' => [
'autocomplete_filter' => [
'type' => 'edge_ngram',
'min_gram' => 2,
'max_gram' => 20,
],
'german_stemmer' => ['type' => 'stemmer', 'language' => 'german'],
'german_stop' => ['type' => 'stop', 'stopwords' => '_german_'],
],
],
],
];
3. Index-Mapping in PHP definieren
Das Index-Mapping ist die Konfiguration von Elasticsearch, die festlegt, wie Felder analysiert, tokenisiert und gespeichert werden. Es ist das Gegenstück zum Datenbankschema — aber mit entscheidenden Unterschieden: Während SQL-Typen nur bestimmen, wie Daten gespeichert werden, bestimmt das Elasticsearch-Mapping auch, wie Texte für die Suche aufgeteilt (tokenisiert) und normalisiert (Kleinschreibung, Stemming) werden. Ein text-Feld mit einem deutschen Analyzer tokenisiert "Laptoptaschen" in "laptop" und "tasch" (nach Stemming), sodass eine Suche nach "Laptoptasche" oder "Laptoptaschen" dasselbe Ergebnis liefert.
Das Mapping definiert für jeden suchbaren Text zwei Felder: ein text-Feld für Volltextsuche mit Analyzer und ein keyword-Feld für exakte Suche, Filterung und Sortierung. Das keyword-Feld ist als fields: {raw: {type: keyword}} ein Unter-Feld des text-Feldes. So kann man gleichzeitig nach "laptop" im Text suchen und nach dem exakten Kategorie-Namen "Computer & Zubehör" filtern. Numerische Felder für Preise, Bewertungen und Bestandszahlen bekommen typenspezifische Elasticsearch-Typen: float, integer oder scaled_float. Datumsfelder als date mit explizitem Format ermöglichen Zeitraum-Filter.
4. Doctrine-Entities automatisch indexieren
Die Synchronisation von Doctrine-Entities mit dem Elasticsearch-Index erfolgt über Doctrine Event Listener: Beim Erstellen, Aktualisieren und Löschen einer Entity wird ein entsprechendes Elasticsearch-Dokument erstellt, aktualisiert oder gelöscht. Für einfache Setups reicht das direkte Aufrufen des Elasticsearch-Clients im postPersist- und postUpdate-Event. Das Problem: Wenn das Symfony-Messenger-Worker-Pattern eingesetzt wird, müssen die Events nach dem Flush, nicht davor, aufgerufen werden, um sicherzustellen, dass das Dokument mit den richtigen IDs indexiert wird.
Für initiales Befüllen des Index oder Neu-Indexierung nach Mapping-Änderungen schreibt man einen Symfony-Command: php bin/console app:search:reindex product. Der Command iteriert in Batches von 500 Entities über alle Produkte, konvertiert sie über den Transformer in Elasticsearch-Dokumente und sendet sie über die Bulk-API von Elasticsearch. Die Bulk-API ist um Größenordnungen schneller als einzelne Index-Requests — 500 Dokumente in einem Bulk-Request statt 500 separater HTTP-Calls. Bei 100.000 Produkten bedeutet das eine Reindexierung in Minuten statt Stunden. Das Flag --reset löscht den Index vor der Reindexierung, sodass veraltete Dokumente automatisch entfernt werden.
<?php
declare(strict_types=1);
namespace App\Search\Application;
use App\Entity\Product;
use App\Repository\ProductRepository;
use Elastic\Elasticsearch\Client;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command to reindex all products into Elasticsearch using the Bulk API.
*/
#[AsCommand(name: 'app:search:reindex', description: 'Reindex all products into Elasticsearch')]
final class ReindexProductsCommand extends Command
{
private const BATCH_SIZE = 500;
private const INDEX_NAME = 'products';
public function __construct(
private readonly Client $elasticsearch,
private readonly ProductRepository $productRepository,
private readonly ProductDocumentTransformer $transformer,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('reset', null, InputOption::VALUE_NONE, 'Delete and recreate the index before reindexing');
}
/**
* Iterate all products in batches and index them via Elasticsearch Bulk API.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('reset')) {
$this->elasticsearch->indices()->delete(['index' => self::INDEX_NAME, 'ignore_unavailable' => true]);
$this->elasticsearch->indices()->create(['index' => self::INDEX_NAME, 'body' => $this->transformer->getIndexMapping()]);
$io->info('Index reset and recreated.');
}
$total = $this->productRepository->count([]);
$indexed = 0;
$offset = 0;
$io->progressStart($total);
while ($offset < $total) {
$products = $this->productRepository->findBy([], ['id' => 'ASC'], self::BATCH_SIZE, $offset);
// Build Elasticsearch Bulk API body — pairs of action + document
$bulkBody = [];
foreach ($products as $product) {
$bulkBody[] = ['index' => ['_index' => self::INDEX_NAME, '_id' => $product->getId()]];
$bulkBody[] = $this->transformer->transform($product);
}
$this->elasticsearch->bulk(['body' => $bulkBody]);
$indexed += count($products);
$offset += self::BATCH_SIZE;
$io->progressAdvance(count($products));
}
$io->progressFinish();
$io->success(sprintf('Indexed %d products into Elasticsearch.', $indexed));
return Command::SUCCESS;
}
}
5. Multi-Match und Bool-Query für Full-Text-Search
Die Basis jeder Elasticsearch-Suche in Symfony ist die bool-Query: Sie kombiniert mehrere Teilabfragen mit must, should, filter und must_not. must-Bedingungen sind Pflicht und beeinflussen den Relevanz-Score. filter-Bedingungen schränken die Ergebnisse ein, ohne den Score zu beeinflussen — ideal für Kategorie-Filter, Preis-Ranges und Verfügbarkeit. should-Bedingungen erhöhen den Score wenn sie treffen, aber sie sind nicht Pflicht — gut für Boost-Logik: Produkte, die gerade im Angebot sind, erhalten einen höheren Score.
Die multi_match-Query sucht denselben Begriff gleichzeitig in mehreren Feldern und gewichtet sie unterschiedlich. Ein Treffer im Produktnamen (Gewichtung 3) ist relevanter als ein Treffer in der Beschreibung (Gewichtung 1) oder im Kategorie-Namen (Gewichtung 2). Mit type: best_fields nimmt Elasticsearch das beste Feld-Treffer-Score, mit type: most_fields summiert es alle Felder. Für Phrasensuche ("rotes Kleid") empfiehlt sich type: phrase, das Wörter in der richtigen Reihenfolge verlangt. Die Kombination von multi_match mit fuzziness: AUTO erlaubt Tippfehler-Toleranz: "Labtop" findet "Laptop" wegen der Edit-Distance von 1.
6. Facetten und Aggregationen für Filter-Navigation
Facetten-Navigation — die Filterleiste mit "Kategorie", "Preis", "Marke" und Trefferzahlen — ist eines der mächtigsten Features von Elasticsearch in E-Commerce- und Katalog-Anwendungen. Aggregationen berechnen Facetten in derselben Suchanfrage, die auch die Treffer liefert — kein zweiter Request, kein separater COUNT-Query. Eine terms-Aggregation zählt, wie viele Dokumente je Kategorie-Wert vorhanden sind. Eine range-Aggregation zählt Treffer in Preis-Ranges. Eine stats-Aggregation liefert Min, Max, Avg und Summe eines numerischen Feldes.
Das Zusammenspiel von Filtern und Aggregationen in Elasticsearch erfordert das Konzept der "post_filter" und "global"-Aggregationen. Wenn ein Benutzer nach "Kategorie: Laptops" filtert, soll die Kategorie-Facette trotzdem alle Kategorien mit ihren Treffer-Zahlen anzeigen — nicht nur "Laptops". Ohne besondere Behandlung würde die Aggregation nach dem Filter berechnet und nur "Laptops" anzeigen. Mit einer global-Aggregation berücksichtigt Elasticsearch den Kategorie-Filter nicht für die Kategorie-Facette, wohl aber für alle anderen Facetten und die eigentlichen Treffer. Dieses Muster heißt "Sticky Facets" und ist standard in professionellen Such-Interfaces.
<?php
declare(strict_types=1);
namespace App\Search\Application;
use Elastic\Elasticsearch\Client;
/**
* Service for building and executing product search queries against Elasticsearch.
*/
final readonly class ProductSearchService
{
public function __construct(
private Client $elasticsearch,
) {}
/**
* Execute a full-text product search with facets, filters and pagination.
*
* @return array{hits: array, aggregations: array, total: int}
*/
public function search(
string $query,
int $page = 1,
int $perPage = 20,
?string $categoryFilter = null,
?float $minPrice = null,
?float $maxPrice = null,
): array {
$mustClauses = [];
$filterClauses = [];
// Full-text search: name field is weighted 3x over description
if ($query !== '') {
$mustClauses[] = [
'multi_match' => [
'query' => $query,
'fields' => ['name^3', 'category.name^2', 'description'],
'type' => 'best_fields',
'fuzziness' => 'AUTO', // Tolerates 1–2 character typos
],
];
}
// Category filter — does not affect relevance score, only filters results
if ($categoryFilter !== null) {
$filterClauses[] = ['term' => ['category.name.raw' => $categoryFilter]];
}
// Price range filter — uses scaled_float mapping
if ($minPrice !== null || $maxPrice !== null) {
$rangeFilter = [];
if ($minPrice !== null) { $rangeFilter['gte'] = $minPrice; }
if ($maxPrice !== null) { $rangeFilter['lte'] = $maxPrice; }
$filterClauses[] = ['range' => ['price' => $rangeFilter]];
}
$response = $this->elasticsearch->search([
'index' => 'products',
'body' => [
'from' => ($page - 1) * $perPage,
'size' => $perPage,
'query' => [
'bool' => [
'must' => $mustClauses ?: [['match_all' => (object)[]]],
'filter' => $filterClauses,
],
],
// Aggregations for faceted navigation — computed alongside search results
'aggs' => [
'categories' => ['terms' => ['field' => 'category.name.raw', 'size' => 20]],
'price_stats' => ['stats' => ['field' => 'price']],
'price_ranges' => [
'range' => [
'field' => 'price',
'ranges' => [
['to' => 50],
['from' => 50, 'to' => 100],
['from' => 100, 'to' => 500],
['from' => 500],
],
],
],
],
],
]);
return [
'hits' => $response['hits']['hits'],
'total' => $response['hits']['total']['value'],
'aggregations' => $response['aggregations'],
];
}
}
7. Autocomplete mit Edge-N-Gram-Analyzer
Autocomplete in Elasticsearch funktioniert über Edge-N-Gram-Analyzer: Der Text "Laptop" wird beim Indexieren in "La", "Lap", "Lapt", "Lapto" und "Laptop" aufgeteilt. Bei der Suche wird nur der eingegebene Prefix gesucht — "Lap" findet alle Dokumente, die beim Indexieren "Lap" als Token hatten. Das Ergebnis: Eine Instant-Suche, die nach jedem Buchstaben Treffer liefert, ohne einen Wildcard-Query zu benötigen. Die Suchzeit ist konstant, unabhängig davon, wie früh der Benutzer aufhört zu tippen.
Die Konfiguration des Edge-N-Gram-Analyzers erfolgt im Index-Mapping: Ein Custom-Analyzer autocomplete nutzt das edge_ngram-Token-Filter mit min_gram: 2 und max_gram: 20. Wichtig: Der Analyzer wird nur beim Indexieren verwendet — beim Suchen verwendet man den Standard-Analyzer, der den eingegebenen Text nicht weiter aufteilt. Das erreicht man durch "search_analyzer": "standard" auf dem Feld. Das Autocomplete-Feld ist ein separates Feld auf dem Dokument — das Haupt-Namensfeld bleibt für die reguläre Suche unverändert. Eine typische Autocomplete-Anfrage gibt maximal 10 Suggestions zurück und kann mit einem bool filter auf lagernd/aktive Produkte eingeschränkt werden.
8. Asynchrone Indexierung mit Symfony Messenger
Synchrone Elasticsearch-Indexierung direkt im Doctrine PostFlush-Event hat einen Nachteil: Wenn Elasticsearch nicht erreichbar ist oder langsam antwortet, verlängert das jeden Schreibzugriff in der Anwendung. Die robustere Variante ist asynchrone Indexierung über Symfony Messenger: Nach dem Persistieren einer Entity dispatcht ein Doctrine-Subscriber eine IndexProductMessage in die Messenger-Queue. Ein Worker-Prozess verarbeitet die Nachricht asynchron und sendet das Dokument an Elasticsearch. Wenn Elasticsearch nicht erreichbar ist, landet die Nachricht in der Retry-Queue und wird später wiederholt.
Das Muster hat noch einen weiteren Vorteil: Mehrfach-Updates in kurzer Zeit werden vom Worker gebündelt. Wenn ein Produkt in einer Batch-Operation 50 Mal aktualisiert wird, enthält die Queue 50 IndexProductMessage-Nachrichten. Ein Idempotenz-Mechanismus oder eine Deduplizierungs-Middleware stellt sicher, dass nur der letzte Stand indexiert wird, ohne 50 separate Elasticsearch-Requests. Die Kombination aus Symfony Messenger, Retry-Logik und asynchroner Indexierung macht das Elasticsearch-Setup resilienter gegenüber externen Dienstausfällen — ein kritischer Gesichtspunkt für Produktionssysteme.
9. Elasticsearch vs. SQL LIKE im direkten Vergleich
Der direkte Vergleich macht deutlich, wann Elasticsearch den Aufwand der Integration rechtfertigt — und wann eine SQL-basierte Suche ausreicht.
| Kriterium | SQL LIKE / FULLTEXT | Elasticsearch | Vorteil |
|---|---|---|---|
| Performance bei 1M+ Datensätzen | Langsam (Full-Table-Scan) | < 10 ms (invertierter Index) | Elasticsearch deutlich schneller |
| Tippfehler-Toleranz | Keine | Fuzzy-Matching (fuzziness: AUTO) | Elasticsearch |
| Relevanz-Ranking | Nicht vorhanden | TF-IDF, BM25, Custom Boost | Elasticsearch |
| Facetten/Aggregationen | Separate COUNT-Queries | In einer Suchanfrage | Elasticsearch |
| Betriebsaufwand | Keine extra Infrastruktur | Elasticsearch-Cluster, Monitoring | SQL für kleine Projekte |
Die Faustregel: Bis ca. 50.000 Datensätze und ohne Anforderungen an Relevanz-Ranking oder Facetten-Navigation ist SQL FULLTEXT oder Doctrine LIKE ausreichend. Ab 100.000 Datensätzen, bei E-Commerce-Suchanforderungen oder wenn Tippfehler-Toleranz erwartet wird, ist Elasticsearch die richtige Wahl. Der Betriebsaufwand für einen einzelnen Elasticsearch-Node ist überschaubar — managed Services wie Elastic Cloud oder AWS OpenSearch eliminieren den Betriebsaufwand fast vollständig.
Mironsoft
Symfony-Entwicklung, Elasticsearch-Integration und skalierbare Suche
Elasticsearch in euer Symfony-Projekt integrieren?
Wir implementieren Full-Text-Suche mit Elasticsearch in Symfony-Projekten — von Index-Mapping und Doctrine-Synchronisation über Multi-Match-Queries und Facetten bis zu Autocomplete und asynchroner Indexierung.
Index-Design
Mapping, Analyzer und Shard-Konfiguration für optimale Such-Performance und Relevanz-Ranking
Sync-Architektur
Asynchrone Indexierung mit Symfony Messenger, Bulk-API und Retry-Logik für zuverlässige Datensynchronisation
Such-Features
Facetten-Navigation, Autocomplete, Fuzzy-Matching und Relevanz-Tuning für professionelle Sucherfahrungen
10. Zusammenfassung
Elasticsearch in Symfony bringt Full-Text-Search-Fähigkeiten, die mit SQL-Datenbanken nicht erreichbar sind: Millisekunden-Antwortzeiten bei Millionen von Dokumenten, Tippfehler-Toleranz durch Fuzzy-Matching, Relevanz-Ranking durch TF-IDF/BM25 und Facetten-Aggregationen in einer einzigen Suchanfrage. Die Architektur — Doctrine als Source of Truth, Elasticsearch als spezialisierter Suchindex — hält beide Systeme konsistent und vermeidet Daten-Redundanz-Probleme. Asynchrone Indexierung über Symfony Messenger macht das System resilienter gegenüber Elasticsearch-Ausfällen.
Der Aufbau beginnt mit einem präzisen Index-Mapping, das Felder korrekt analysiert und für Suche vs. Filter-Verwendung konfiguriert. Der Reindex-Command mit der Bulk-API sorgt für schnelle Initialbefüllung und Neubefüllung nach Mapping-Änderungen. Multi-Match-Queries mit Feld-Gewichtung und Fuzziness ergeben sofort bessere Suchergebnisse als jede SQL-basierte Variante. Autocomplete über Edge-N-Gram-Analyzer und Facetten über Aggregationen runden das Such-Interface zu einer professionellen Lösung ab.
Symfony + Elasticsearch — Das Wichtigste auf einen Blick
Mapping zuerst
text-Felder mit Analyzer für Volltextsuche + keyword-Subfeld für Filter/Sortierung. Mapping-Änderungen erfordern Reindexierung — deshalb gründlich planen.
Bulk-API nutzen
Reindexierung in Batches von 500 mit Bulk-API — hundertmal schneller als Einzelanfragen. --reset löscht und erstellt den Index neu.
Bool + Multi-Match
bool-Query kombiniert must (Pflicht, beeinflusst Score) und filter (nur Einschränkung). multi_match sucht in mehreren Feldern mit individueller Gewichtung.
Async indexieren
Symfony Messenger für asynchrone Indexierung — HTTP-Response bleibt schnell, Elasticsearch-Ausfälle werden durch Retry-Logik abgefangen.