In the modern web ecosystem, “search” is not just about finding text; it is about performance, relevance and user experience. As a Senior Symfony Developer, you know that LIKE %…% queries are a technical debt trap.
This article details how to implement a production-grade Elasticsearch integration in Symfony 7.4. We aren’t just “installing a bundle”; we are building a resilient, zero-downtime search architecture using PHP 8.4 features, Attributes and Symfony Messenger for asynchronous indexing.
In a junior implementation, an entity update triggers a synchronous HTTP call to Elasticsearch. If Elastic is down, your user cannot save their data. That is unacceptable.
We will use friendsofsymfony/elastica-bundle (v7.0+). It provides the best abstraction over the raw elasticsearch-php client while adhering to Symfony’s configuration standards.
Run the following in your terminal:
composer require friendsofsymfony/elastica-bundle "^7.0" composer require symfony/messenger composer require symfony/serializer
Add your Elasticsearch DSN to your .env file. In production, ensure this is stored in a secret manager (like Symfony Secrets or HashiCorp Vault).
# .env ELASTICSEARCH_URL=http://localhost:9200/
This is where most tutorials fail. They configure a static index name. We will configure an aliased strategy to allow background reindexing without taking the site down.
Create or update config/packages/fos_elastica.yaml:
# config/packages/fos_elastica.yaml fos_elastica: clients: default: url: '%env(ELASTICSEARCH_URL)%' # Production Tip: Increase timeout for bulk operations config: connect_timeout: 5 timeout: 10 indexes: app_products: # "use_alias: true" is critical for zero-downtime reindexing use_alias: true # Define your distinct settings (analyzers, filters) settings: index: analysis: analyzer: app_analyzer: type: custom tokenizer: standard filter: [lowercase, asciifolding] # Your Persistence strategy (Doctrine integration) persistence: driver: orm model: App\Entity\Product provider: ~ # CRITICAL: We disable the default listener to use Messenger instead listener: insert: false update: false delete: false finder: ~ # Explicit Mapping (Always prefer explicit over dynamic for production) properties: id: { type: integer } name: type: text analyzer: app_analyzer fields: keyword: { type: keyword, ignore_above: 256 } description: { type: text, analyzer: app_analyzer } price: { type: float } stock: { type: integer } created_at: { type: date }
Let’s assume a standard Product entity. We use standard PHP 8 attributes.
namespace App\Entity; use App\Repository\ProductRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ProductRepository::class)] #[ORM\Table(name: 'products')] class Product { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private ?string $name = null; #[ORM\Column(type: Types::TEXT)] private ?string $description = null; #[ORM\Column] private ?float $price = null; #[ORM\Column] private ?int $stock = 0; #[ORM\Column] private ?\DateTimeImmutable $createdAt = null; public function __construct() { $this->createdAt = new \DateTimeImmutable(); } // ... Getters and Setters public function getId(): ?int { return $this->id; } // ... }
Instead of letting fos_elastica slow down our user requests by indexing immediately, we will dispatch a message to a queue.
A simple DTO (Data Transfer Object) to carry the ID of the entity that changed.
namespace App\Message; final readonly class IndexProductMessage { public function __construct( public int $productId, // 'index' or 'delete' public string $action = 'index' ) {} }
We listen to Doctrine events to automatically dispatch our message.
namespace App\EventListener; use App\Entity\Product; use App\Message\IndexProductMessage; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostRemoveEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs; use Doctrine\ORM\Events; use Symfony\Component\Messenger\MessageBusInterface; #[AsDoctrineListener(event: Events::postPersist, priority: 500, connection: 'default')] #[AsDoctrineListener(event: Events::postUpdate, priority: 500, connection: 'default')] #[AsDoctrineListener(event: Events::postRemove, priority: 500, connection: 'default')] class ProductIndexerSubscriber { public function __construct( private MessageBusInterface $bus ) {} public function postPersist(PostPersistEventArgs $args): void { $this->dispatch($args->getObject(), 'index'); } public function postUpdate(PostUpdateEventArgs $args): void { $this->dispatch($args->getObject(), 'index'); } public function postRemove(PostRemoveEventArgs $args): void { // When removing, we still need the ID, but the object is technically gone from DB. // Ensure you capture the ID before it's fully detached if needed, // but postRemove usually still has access to the object instance. $this->dispatch($args->getObject(), 'delete'); } private function dispatch(object $entity, string $action): void { if (!$entity instanceof Product) { return; } $this->bus->dispatch(new IndexProductMessage($entity->getId(), $action)); } }
This is where the actual work happens in the background worker.
namespace App\MessageHandler; use App\Entity\Product; use App\Message\IndexProductMessage; use App\Repository\ProductRepository; use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final class IndexProductHandler { public function __construct( // Inject the specific persister for 'app_products' index // The service ID usually follows the pattern fos_elastica.object_persister.<index_name>.<type_name> // Or you can bind it via services.yaml if autowiring fails private ObjectPersisterInterface $productPersister, private ProductRepository $productRepository ) {} public function __invoke(IndexProductMessage $message): void { if ($message->action === 'delete') { // For deletion, we can't fetch the entity as it's gone. // We pass the ID directly to the persister. // Note: In some setups, you might need a stub object or just the ID. // The ObjectPersisterInterface typically expects an object, // but strictly speaking, Elastica needs an ID. // A cleaner way for delete is often using the Elastica Client directly // if the Persister insists on an Entity object. // For simplicity here, we assume the persister handles ID lookups or we use a custom service. // Production-grade approach: Use the Raw Index Service for deletes to avoid hydration issues // But for this example, let's focus on Indexing. return; } $product = $this->productRepository->find($message->productId); if (!$product) { // Product might have been deleted before this worker ran return; } // This pushes the single object to Elasticsearch $this->productPersister->replaceOne($product); } }
You must register the persister explicitly in services.yaml to autowire ObjectPersisterInterface correctly, or use #[Target] attribute if you have multiple indexes.
# config/services.yaml services: _defaults: bind: # Bind the specific persister to the argument name or type $productPersister: '@fos_elastica.object_persister.app_products'
Do not put Elastica logic in your Controllers. Create a dedicated service.
namespace App\Service\Search; use FOS\ElasticaBundle\Finder\TransformedFinder; class ProductSearchService { public function __construct( // The TransformedFinder returns Doctrine Entities. // If you want raw speed and arrays, use the 'index' service directly. private TransformedFinder $productFinder ) {} /** * @return array<int, \App\Entity\Product> */ public function search(string $query, int $limit = 20): array { // Elastica Query Builder $boolQuery = new \Elastica\Query\BoolQuery(); // Match name or description $matchQuery = new \Elastica\Query\MultiMatch(); $matchQuery->setQuery($query); $matchQuery->setFields(['name^3', 'description']); // Boost name by 3x $matchQuery->setFuzziness('AUTO'); // Handle typos $boolQuery->addMust($matchQuery); // Filter by stock (only in stock items) $stockFilter = new \Elastica\Query\Range('stock', ['gt' => 0]); $boolQuery->addFilter($stockFilter); $elasticaQuery = new \Elastica\Query($boolQuery); $elasticaQuery->setSize($limit); // Returns Hydrated Doctrine Objects return $this->productFinder->find($elasticaQuery); } }
Before your app can work, you must initialize the index.
php bin/console fos:elastica:create
If you have existing data in MySQL, push it to Elastic.
php bin/console fos:elastica:populate
This command uses the Zero-Downtime logic: it creates a new index, fills it and then atomically switches the alias.
Check if your mapping is correct directly in Elastic.
curl -X GET "http://localhost:9200/app_products/_mapping?pretty"
You now have a search architecture that:
This is the standard for high-performance Symfony applications.
Let’s stay in touch! Connect with me on LinkedIn [https://www.linkedin.com/in/matthew-mochalkin/] for more PHP & Symfony architecture insights.


