Symfony + Elasticsearch: Integrating Full-Text Search
AI generated
SF
{ }
Symfony · Elasticsearch · Full-Text Search · FOSElastica
Symfony + Elasticsearch:
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.

21 min read Mapping · Indexing · Multi-Match · Facets · Autocomplete Symfony 7.x · PHP 8.3+ · Elasticsearch 8.x

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.

11. FAQ: Symfony + Elasticsearch

1Why Elasticsearch instead of SQL LIKE?
SQL LIKE with a leading wildcard cannot be indexed and is slow. Elasticsearch: inverted index for ms response times, fuzzy matching, relevance ranking, none of which SQL can provide.
2What is FOSElasticaBundle?
A Symfony bundle for Elasticsearch integration: index configuration, Doctrine synchronization, transformer and paginator. Abstracts the boilerplate of direct client usage.
3What is an index mapping?
Configures how fields are analyzed: text for full-text search with tokenization, keyword for exact search/filter/sort. Mapping changes require reindexing.
4Implementing autocomplete?
Edge n-gram analyzer at index time: "Laptop" becomes "La", "Lap", "Lapt", etc. Standard analyzer at search time. Configure search_analyzer: standard on the field.
5What is multi-match?
Searches the same term in several fields with weighting: name^3, description^1. A hit in the name is three times more relevant than in the description. Type best_fields for the best single field score.
6How do facets work?
Aggregations within the same search request: terms for category counts, range for price ranges. Results arrive together with the hits, no separate COUNT request needed.
7Doctrine sync with Elasticsearch?
A Doctrine event subscriber dispatches a Messenger message after postPersist/postUpdate. A worker processes it asynchronously. Elasticsearch latency does not slow down the HTTP response.
8What is the Bulk API?
Multiple index/delete operations in one HTTP request. 100,000 documents in 200 bulk requests (of 500 each) instead of 100,000 individual requests. The speed difference is hours versus minutes.
9What is fuzzy matching?
Allows an edit distance between the search query and the indexed term. fuzziness: AUTO allows 1 to 2 letter errors. "Labtop" finds "Laptop". Significantly better search quality on typos.
10When is Elasticsearch worth it?
From 100,000 records upward, or when relevance ranking, typo tolerance or facet navigation is needed. For smaller projects without these requirements, SQL FULLTEXT is enough.