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:
- 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.
- Async Indexing: Database writes are decoupled from Search writes using Symfony Messenger.
- 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
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/
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 }
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;
}
// ...
}
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'
) {}
}
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));
}
}
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);
}
}
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'
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);
}
}
Verification & Deployment
Create the Index
Before your app can work, you must initialize the index.
php bin/console fos:elastica:create
Populate Data (Initial Load)
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.
Verify via cURL
Check if your mapping is correct directly in Elastic.
curl -X GET "http://localhost:9200/app_products/_mapping?pretty"
Conclusions
You now have a search architecture that:
- Survives DB load: Searching hits Elastic, not MySQL.
- Survives Elastic downtime: Messages queue up in Messenger (RabbitMQ/Redis) and retry later.
- 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)