DEV Community

Cover image for Production-Ready ElasticSearch with Symfony 7.4: The Senior Guide
Matt Mochalkin
Matt Mochalkin

Posted on

Production-Ready ElasticSearch with Symfony 7.4: The Senior Guide

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.

The Architecture: Performance & Resilience

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.

Our Production Strategy:

  1. Zero-Downtime Indexing: We never write directly to the live index. We write to a time-stamped index and use an Alias to point the app to the current live version.
  2. Async Indexing: Database writes are decoupled from Search writes using Symfony Messenger.
  3. Strict Typing: We use DTOs and strongly typed services, avoiding “magic arrays” where possible.

Prerequisites & Installation

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.

Environment Requirements:

  • PHP 8.2+ (rec. 8.4)
  • Symfony 7.4
  • Elasticsearch 8.x

Install Dependencies

Run the following in your terminal:

composer require friendsofsymfony/elastica-bundle "^7.0"
composer require symfony/messenger
composer require symfony/serializer
Enter fullscreen mode Exit fullscreen mode

Environment Configuration

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/
Enter fullscreen mode Exit fullscreen mode

The “Zero-Downtime” Setup

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 }
Enter fullscreen mode Exit fullscreen mode

The Domain Layer

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;
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Async Indexing with Messenger (The Senior Pattern)

Instead of letting fos_elastica slow down our user requests by indexing immediately, we will dispatch a message to a queue.

The Message

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'
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The Lifecycle Event Subscriber

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

The Handler

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Searching: The Repository Pattern

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Verification & Deployment

Create the Index

Before your app can work, you must initialize the index.

php bin/console fos:elastica:create
Enter fullscreen mode Exit fullscreen mode

Populate Data (Initial Load)

If you have existing data in MySQL, push it to Elastic.

php bin/console fos:elastica:populate
Enter fullscreen mode Exit fullscreen mode

This command uses the Zero-Downtime logic: it creates a new index, fills it and then atomically switches the alias.

Verify via cURL

Check if your mapping is correct directly in Elastic.

curl -X GET "http://localhost:9200/app_products/_mapping?pretty"
Enter fullscreen mode Exit fullscreen mode

Conclusions

You now have a search architecture that:

  1. Survives DB load: Searching hits Elastic, not MySQL.
  2. Survives Elastic downtime: Messages queue up in Messenger (RabbitMQ/Redis) and retry later.
  3. Survives Reindexing: You can change your analyzers and mappings, run fos:elastica:populate and users won’t notice a thing.

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.

Top comments (0)