Integrating Full-Text Search
Database-driven search with LIKE queries does not scale. Past 100,000 records it becomes slow, it finds nothing on typos, and relevance ranking is impossible. Elasticsearch solves all three problems: millisecond response times, fuzzy matching and configurable relevance scores, integrated into Symfony through a clear indexing and query protocol.
Table of Contents
- 1. Why Elasticsearch instead of SQL LIKE for full-text search?
- 2. Architecture: running Elasticsearch alongside Doctrine
- 3. Defining the index mapping in PHP
- 4. Automatically indexing Doctrine entities
- 5. Multi-match and bool queries for full-text search
- 6. Facets and aggregations for filter navigation
- 7. Autocomplete with an edge n-gram analyzer
- 8. Asynchronous indexing with Symfony Messenger
- 9. Elasticsearch vs. SQL LIKE head to head
- 10. Summary
- 11. FAQ
1. Why Elasticsearch instead of SQL LIKE for full-text search?
The most common way to implement search in Symfony projects is a Doctrine query with LIKE '%searchterm%'. That works for small datasets, but it has fundamental limitations. First, LIKE '%...' with a leading wildcard cannot be indexed. With 500,000 products, MySQL scans the entire table. Second, there is no relevance ranking: all hits are weighted equally even though a product with the search term in its name is more relevant than one that only mentions it in the description. Third, fuzzy matching is missing: "Labtop" does not find "Laptop". Elasticsearch solves all three problems with an inverted index, configurable analyzers and the TF-IDF scoring algorithm.
Integrating Elasticsearch into Symfony follows a clear pattern: Doctrine is the source of truth for all data, and Elasticsearch is a search index populated from Doctrine entities. Changes to entities are propagated to Elasticsearch asynchronously after persisting, either through a Doctrine event listener or through Symfony Messenger. The search itself runs directly against Elasticsearch, and the resulting IDs are then used to load the full entities from Doctrine. This pattern keeps the database as the authoritative source while fully exploiting the search strengths of Elasticsearch.
2. Architecture: running Elasticsearch alongside Doctrine
The architecture of Elasticsearch in Symfony projects is a read/write split at the infrastructure level: all write operations go to Doctrine, all search queries go to Elasticsearch. This split follows the CQRS principle, where the search index is a specialized read view of the data, optimized for full-text search and relevance ranking. Doctrine entities are converted into Elasticsearch documents by transformer classes, which populate the index. The document contains only the fields relevant for search and result display, no internal database fields and no sensitive data.
The FOSElasticaBundle library simplifies the integration considerably. It manages index creation and configuration, automatic synchronization when Doctrine entities are persisted, pagination of search results, and the mapping between Elasticsearch documents and Symfony objects. Alternatively, you can use the official Elasticsearch PHP client directly and implement every aspect yourself, which gives more control over query building but requires more boilerplate for synchronization and index management. For new projects, FOSElasticaBundle is the faster entry point; for complex search architectures the direct client is often worth it.
<?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. Defining the index mapping in PHP
The index mapping is the Elasticsearch configuration that determines how fields are analyzed, tokenized and stored. It is the counterpart to the database schema, but with a crucial difference: while SQL types only determine how data is stored, the Elasticsearch mapping also determines how text is split (tokenized) and normalized (lowercasing, stemming) for search. A text field with a German analyzer tokenizes "Laptoptaschen" into "laptop" and "tasch" (after stemming), so a search for "Laptoptasche" or "Laptoptaschen" returns the same result.
For every searchable text, the mapping defines two fields: a text field for full-text search with an analyzer, and a keyword field for exact search, filtering and sorting. The keyword field is a sub-field of the text field, defined as fields: {raw: {type: keyword}}. This lets you search for "laptop" in the text while filtering by the exact category name "Computers & Accessories" at the same time. Numeric fields for prices, ratings and stock counts get type-specific Elasticsearch types: float, integer or scaled_float. Date fields as date with an explicit format enable date range filters.
4. Automatically indexing Doctrine entities
Doctrine entities are synchronized with the Elasticsearch index through Doctrine event listeners: when an entity is created, updated or deleted, a corresponding Elasticsearch document is created, updated or deleted. For simple setups, calling the Elasticsearch client directly in the postPersist and postUpdate events is enough. The catch: when the Symfony Messenger worker pattern is used, the events must fire after the flush, not before, to make sure the document is indexed with the correct IDs.
For initial population of the index or reindexing after mapping changes, write a Symfony command: php bin/console app:search:reindex product. The command iterates over all products in batches of 500 entities, converts them into Elasticsearch documents via the transformer, and sends them through the Bulk API of Elasticsearch. The Bulk API is orders of magnitude faster than individual index requests: 500 documents in one bulk request instead of 500 separate HTTP calls. For 100,000 products, that means reindexing in minutes instead of hours. The --reset flag deletes the index before reindexing, so stale documents are automatically removed.
<?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 and bool queries for full-text search
The foundation of every Elasticsearch search in Symfony is the bool query, which combines several sub-queries with must, should, filter and must_not. must conditions are mandatory and affect the relevance score. filter conditions narrow the results without affecting the score, ideal for category filters, price ranges and availability. should conditions raise the score when they match but are not mandatory, which is useful for boost logic: products currently on sale get a higher score.
The multi_match query searches the same term in several fields at once and weights them differently. A hit in the product name (weight 3) is more relevant than a hit in the description (weight 1) or in the category name (weight 2). With type: best_fields, Elasticsearch takes the best single field score; with type: most_fields it sums all fields. For phrase search ("red dress"), type: phrase is recommended, requiring the words in the correct order. Combining multi_match with fuzziness: AUTO allows for typo tolerance: "Labtop" finds "Laptop" thanks to an edit distance of 1.
6. Facets and aggregations for filter navigation
Facet navigation, the filter sidebar with "Category", "Price", "Brand" and hit counts, is one of the most powerful features of Elasticsearch in e-commerce and catalog applications. Aggregations compute facets within the same search request that also returns the hits, no second request, no separate COUNT query. A terms aggregation counts how many documents exist per category value. A range aggregation counts hits within price ranges. A stats aggregation returns min, max, avg and sum of a numeric field.
The interaction between filters and aggregations in Elasticsearch requires the concept of "post_filter" and "global" aggregations. When a user filters by "Category: Laptops", the category facet should still show all categories with their hit counts, not just "Laptops". Without special handling, the aggregation would be computed after the filter and only show "Laptops". With a global aggregation, Elasticsearch ignores the category filter for the category facet, but still applies it to all other facets and the actual hits. This pattern is called "sticky facets" and is standard in professional search 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 to 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 with an edge n-gram analyzer
Autocomplete in Elasticsearch works through edge n-gram analyzers: the text "Laptop" is split during indexing into "La", "Lap", "Lapt", "Lapto" and "Laptop". At search time, only the entered prefix is searched, "Lap" finds every document that had "Lap" as a token during indexing. The result is instant search that returns hits after every keystroke, without needing a wildcard query. Search time is constant, regardless of how early the user stops typing.
The edge n-gram analyzer is configured in the index mapping: a custom analyzer called autocomplete uses the edge_ngram token filter with min_gram: 2 and max_gram: 20. Important: the analyzer is only used at index time, at search time the standard analyzer is used, which does not further split the entered text. This is achieved with "search_analyzer": "standard" on the field. The autocomplete field is a separate field on the document, the main name field remains unchanged for regular search. A typical autocomplete request returns at most 10 suggestions and can be restricted with a bool filter to in-stock or active products.
8. Asynchronous indexing with Symfony Messenger
Synchronous Elasticsearch indexing directly in the Doctrine post-flush event has a downside: if Elasticsearch is unreachable or responds slowly, it lengthens every write in the application. The more robust variant is asynchronous indexing through Symfony Messenger: after persisting an entity, a Doctrine subscriber dispatches an IndexProductMessage onto the Messenger queue. A worker process handles the message asynchronously and sends the document to Elasticsearch. If Elasticsearch is unreachable, the message lands in the retry queue and is retried later.
The pattern has another advantage: multiple updates in a short time are batched by the worker. If a product is updated 50 times during a batch operation, the queue contains 50 IndexProductMessage messages. An idempotency mechanism or a deduplication middleware ensures that only the latest state is indexed, without 50 separate Elasticsearch requests. Combining Symfony Messenger, retry logic and asynchronous indexing makes the Elasticsearch setup more resilient against external service outages, a critical consideration for production systems.
9. Elasticsearch vs. SQL LIKE head to head
A direct comparison makes clear when Elasticsearch justifies the integration effort, and when SQL-based search is sufficient.
| Criterion | SQL LIKE / FULLTEXT | Elasticsearch | Advantage |
|---|---|---|---|
| Performance at 1M+ records | Slow (full table scan) | < 10 ms (inverted index) | Elasticsearch significantly faster |
| Typo tolerance | None | Fuzzy matching (fuzziness: AUTO) | Elasticsearch |
| Relevance ranking | Not available | TF-IDF, BM25, custom boost | Elasticsearch |
| Facets/aggregations | Separate COUNT queries | Within a single search request | Elasticsearch |
| Operational overhead | No extra infrastructure | Elasticsearch cluster, monitoring | SQL for small projects |
The rule of thumb: up to roughly 50,000 records, and without requirements for relevance ranking or facet navigation, SQL FULLTEXT or Doctrine LIKE is sufficient. From 100,000 records upward, for e-commerce search requirements, or when typo tolerance is expected, Elasticsearch is the right choice. The operational overhead of a single Elasticsearch node is manageable, and managed services such as Elastic Cloud or AWS OpenSearch eliminate almost all of the operational burden.
Mironsoft
Symfony development, Elasticsearch integration and scalable search
Want Elasticsearch integrated into your Symfony project?
We implement full-text search with Elasticsearch in Symfony projects, from index mapping and Doctrine synchronization through multi-match queries and facets to autocomplete and asynchronous indexing.
Index design
Mapping, analyzers and shard configuration for optimal search performance and relevance ranking
Sync architecture
Asynchronous indexing with Symfony Messenger, Bulk API and retry logic for reliable data synchronization
Search features
Facet navigation, autocomplete, fuzzy matching and relevance tuning for professional search experiences
10. Summary
Elasticsearch in Symfony brings full-text search capabilities that are out of reach for SQL databases: millisecond response times across millions of documents, typo tolerance through fuzzy matching, relevance ranking through TF-IDF/BM25, and facet aggregations within a single search request. The architecture, with Doctrine as the source of truth and Elasticsearch as a specialized search index, keeps both systems consistent and avoids data redundancy problems. Asynchronous indexing through Symfony Messenger makes the system more resilient against Elasticsearch outages.
Building it starts with a precise index mapping that analyzes fields correctly and configures them for search versus filter use. The reindex command with the Bulk API delivers fast initial population and repopulation after mapping changes. Multi-match queries with field weighting and fuzziness immediately yield better search results than any SQL-based variant. Autocomplete through an edge n-gram analyzer and facets through aggregations round out the search interface into a professional solution.
Symfony + Elasticsearch: The Essentials at a Glance
Mapping first
text fields with an analyzer for full-text search plus a keyword sub-field for filtering/sorting. Mapping changes require reindexing, so plan carefully.
Use the Bulk API
Reindex in batches of 500 with the Bulk API, a hundred times faster than individual requests. --reset deletes and recreates the index.
Bool + multi-match
A bool query combines must (mandatory, affects score) and filter (restriction only). multi_match searches multiple fields with individual weighting.
Index asynchronously
Symfony Messenger for asynchronous indexing, the HTTP response stays fast, and Elasticsearch outages are absorbed by retry logic.