YOUR DIGITAL HUB
← Retour au blog

Elasticsearch en PHP et Symfony : recherche, agrégations, relevance tuning

· 11 min de lecture
Visuel de couverture — Elasticsearch en PHP et Symfony

Quand PostgreSQL full-text ne suffit plus

PostgreSQL offre tsvector et tsquery, et pour 80% des cas de recherche c'est suffisant. Sur une table de produits, une recherche approximative avec le dictionnaire français et pg_trgm tient 90% du besoin. La question n'est pas de remplacer Postgres par Elasticsearch mais de savoir quand la recherche devient un sous-système à part entière.

Les signaux qui nous poussent à sortir de Postgres :

  • Relevance tuning fin : boost par champ, fonctions de score, rescorers. Difficile à maintenir en SQL.
  • Facettes et agrégations temps réel : filtrage dynamique sur 20 dimensions (prix, marque, catégorie, tags). Très lent en SQL sans index dédié.
  • Multilingue : analyzers spécifiques (stemming, synonymes) par langue. PostgreSQL gère quelques langues, pas toutes.
  • Typo tolerance : correspondance approximative avec distance d'édition. Possible en Postgres via pg_trgm mais moins maîtrisable.
  • Volume : plus de 10 millions de documents avec requêtes sous 100 ms au p95.
  • Lecture découplée des écritures : séparer le workload de recherche des transactions métier.

Dès qu'on coche trois ou plus, un moteur de recherche dédié devient rentable.

Elasticsearch, OpenSearch, Meilisearch, Typesense

Quatre options dominent en 2026. Notre comparatif.

Critère Elasticsearch 8/9 OpenSearch Meilisearch Typesense
Licence Elastic License v2 (propriétaire) Apache 2.0 MIT GPL v3
Fork de - Elasticsearch 7.10 - -
Écosystème Le plus mature Mature, compatible AWS Jeune mais rapide Similaire à Meilisearch
Vector search natif Oui (8.x+) Oui Oui (2024+) Oui
Complexité opérationnelle Élevée Élevée Faible Faible
Agrégations riches Oui, meilleures du marché Oui Limitées Limitées
Relevance tuning fin Oui Oui Limité Limité
Coûts cloud ~1 node 8 Go ~100 EUR/mois ~90 EUR (AWS) ~30 EUR ~30 EUR
Notre recommandation Moteur complet, agrégations, ES classique Contrainte AWS ou licence Catalogue produit simple Alternative Meilisearch

Notre règle : Elasticsearch par défaut pour les projets e-commerce, B2B et analytics riches. Meilisearch pour les cas simples (site de contenu, catalogue modeste) où on veut déployer en une journée. OpenSearch si l'infrastructure est déjà sur AWS et qu'on veut éviter la licence Elastic.

Setup Elasticsearch local

Docker Compose que nous utilisons pour démarrer.

# docker-compose.yml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.14.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=true
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD}
      - ES_JAVA_OPTS=-Xms2g -Xmx2g
    volumes:
      - es_data:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
    healthcheck:
      test: ["CMD", "curl", "-fsSL", "-u", "elastic:${ELASTIC_PASSWORD}", "http://localhost:9200/_cluster/health"]
      interval: 10s
      timeout: 5s
      retries: 10

  kibana:
    image: docker.elastic.co/kibana/kibana:8.14.0
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
      - ELASTICSEARCH_USERNAME=kibana_system
      - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}
    ports:
      - "5601:5601"
    depends_on:
      elasticsearch:
        condition: service_healthy

volumes:
  es_data:

Sizing basique pour une prod : commencer à 1 node 8 Go RAM, scaler à 3 nodes dès 10 millions de documents ou 500 requêtes/seconde. Heap JVM à 50% de la RAM max.

Clients PHP : officiel vs Elastica

Deux clients dominent l'écosystème PHP.

elasticsearch/elasticsearch (officiel)

Maintenu par Elastic, proche 1:1 des API REST. Configuration explicite, typage fort, pagination manuelle. Notre choix par défaut sur les nouveaux projets.

use Elastic\Elasticsearch\ClientBuilder;

$client = ClientBuilder::create()
    ->setHosts(['http://elasticsearch:9200'])
    ->setBasicAuthentication('elastic', getenv('ELASTIC_PASSWORD'))
    ->build();

$response = $client->search([
    'index' => 'products',
    'body' => [
        'query' => [
            'multi_match' => [
                'query' => 'casque bluetooth',
                'fields' => ['title^3', 'description', 'brand^2'],
            ],
        ],
        'size' => 20,
    ],
]);

ruflin/elastica

DSL PHP fluent pour construire les requêtes, évite les tableaux imbriqués. Plus lisible sur des requêtes complexes, mais ajoute une couche à maintenir. Intéressant sur les projets qui construisent beaucoup de requêtes dynamiques.

Notre recommandation

Client officiel pour les nouvelles intégrations, sauf si l'équipe est déjà à l'aise avec Elastica. Éviter FOSElasticaBundle historique sur du Symfony moderne : il s'intègre mal avec les APIs 8.x et ses abstractions cachent plus qu'elles n'aident.

Mapping explicite, jamais dynamique

Le mapping est le schéma de vos documents. Elasticsearch peut l'inférer automatiquement, mais c'est une fausse bonne idée : un champ détecté à tort comme text au lieu de keyword, et les agrégations cassent silencieusement.

PUT /products-v1
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "french_product": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "french_stop", "french_stemmer", "asciifolding"]
        },
        "edge_ngram_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "asciifolding", "edge_ngram_filter"]
        }
      },
      "filter": {
        "french_stop": { "type": "stop", "stopwords": "_french_" },
        "french_stemmer": { "type": "stemmer", "language": "light_french" },
        "edge_ngram_filter": { "type": "edge_ngram", "min_gram": 2, "max_gram": 15 }
      }
    }
  },
  "mappings": {
    "properties": {
      "id": { "type": "keyword" },
      "sku": { "type": "keyword" },
      "title": {
        "type": "text",
        "analyzer": "french_product",
        "fields": {
          "raw": { "type": "keyword" },
          "autocomplete": { "type": "text", "analyzer": "edge_ngram_analyzer", "search_analyzer": "french_product" }
        }
      },
      "description": { "type": "text", "analyzer": "french_product" },
      "brand": { "type": "keyword" },
      "price_cents": { "type": "integer" },
      "currency": { "type": "keyword" },
      "stock": { "type": "integer" },
      "tags": { "type": "keyword" },
      "category_path": { "type": "keyword" },
      "published_at": { "type": "date" },
      "rating_avg": { "type": "float" },
      "rating_count": { "type": "integer" }
    }
  }
}

Le pattern multi-fields est critique : title existe en trois variantes (analysé pour la recherche, raw pour les agrégations exactes, autocomplete pour l'autocomplete edge_ngram). Aucune duplication côté application.

Indexation via Symfony Messenger

Les écritures Elasticsearch doivent être asynchrones. Synchroniser l'indexation avec la transaction métier est un anti-pattern : si Elasticsearch flanche, la transaction échoue. Utiliser Messenger pour publier un événement, un consumer l'indexe.

// src/Search/Message/IndexProductMessage.php
namespace App\Search\Message;

final readonly class IndexProductMessage
{
    public function __construct(
        public int $productId,
        public string $action = 'upsert', // ou 'delete'
    ) {}
}
// src/Search/MessageHandler/IndexProductHandler.php
namespace App\Search\MessageHandler;

use App\Entity\Product;
use App\Search\Message\IndexProductMessage;
use App\Search\ProductIndexer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class IndexProductHandler
{
    public function __construct(
        private EntityManagerInterface $em,
        private ProductIndexer $indexer,
    ) {}

    public function __invoke(IndexProductMessage $message): void
    {
        if ($message->action === 'delete') {
            $this->indexer->delete($message->productId);
            return;
        }
        $product = $this->em->find(Product::class, $message->productId);
        if ($product === null) {
            $this->indexer->delete($message->productId);
            return;
        }
        $this->indexer->upsert($product);
    }
}

Bulk API avec batching

Sur une réindexation complète, ne jamais indexer document par document : 10 à 20 fois plus lent. Utiliser l'API _bulk avec des lots de 500 à 2000 documents.

namespace App\Search;

use App\Entity\Product;
use Elastic\Elasticsearch\Client;
use Doctrine\ORM\EntityManagerInterface;

final class ProductBulkIndexer
{
    private const BATCH_SIZE = 1000;
    private const INDEX = 'products';

    public function __construct(
        private readonly Client $client,
        private readonly EntityManagerInterface $em,
    ) {}

    public function reindexAll(?callable $onProgress = null): int
    {
        $total = 0;
        $batch = [];
        $iterator = $this->em->getRepository(Product::class)
            ->createQueryBuilder('p')
            ->getQuery()
            ->toIterable();

        foreach ($iterator as $product) {
            $batch[] = ['index' => ['_index' => self::INDEX, '_id' => (string) $product->getId()]];
            $batch[] = $this->toDocument($product);

            if (count($batch) >= self::BATCH_SIZE * 2) {
                $this->flush($batch);
                $total += self::BATCH_SIZE;
                $batch = [];
                $this->em->clear();
                $onProgress?->call($this, $total);
            }
        }

        if ($batch !== []) {
            $this->flush($batch);
            $total += count($batch) / 2;
        }
        return (int) $total;
    }

    private function flush(array $body): void
    {
        $response = $this->client->bulk(['body' => $body]);
        $payload = $response->asArray();
        if ($payload['errors'] ?? false) {
            $errors = [];
            foreach ($payload['items'] as $item) {
                $op = array_values($item)[0];
                if (($op['error'] ?? null) !== null) {
                    $errors[] = $op['error'];
                }
            }
            throw new \RuntimeException('Bulk indexing errors: ' . json_encode($errors, JSON_THROW_ON_ERROR));
        }
    }

    private function toDocument(Product $p): array
    {
        return [
            'id' => (string) $p->getId(),
            'sku' => $p->getSku(),
            'title' => $p->getTitle(),
            'description' => $p->getDescription(),
            'brand' => $p->getBrand(),
            'price_cents' => $p->getPriceCents(),
            'currency' => $p->getCurrency(),
            'stock' => $p->getStock(),
            'tags' => $p->getTags(),
            'category_path' => $p->getCategoryPath(),
            'published_at' => $p->getPublishedAt()?->format(DATE_ATOM),
            'rating_avg' => $p->getRatingAvg(),
            'rating_count' => $p->getRatingCount(),
        ];
    }
}

Le $em->clear() après chaque batch est critique : sans lui, Doctrine garde toutes les entités en mémoire et consomme plusieurs gigaoctets sur un million de documents.

Requêtes : bool, multi_match, function_score

Recherche multi-champs avec boost

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "casque bluetooth",
            "fields": ["title^3", "description", "brand^2"],
            "type": "best_fields",
            "operator": "and",
            "fuzziness": "AUTO"
          }
        }
      ],
      "filter": [
        { "term": { "brand": "Sony" } },
        { "range": { "price_cents": { "gte": 5000, "lte": 30000 } } }
      ],
      "should": [
        { "range": { "rating_avg": { "gte": 4.0, "boost": 2.0 } } }
      ]
    }
  },
  "size": 20
}

must filtre et participe au scoring. filter filtre sans scoring (cache-friendly, plus rapide). should boost sans obligation. Cette séparation est le cœur du DSL Elasticsearch.

Agrégations pour les facettes

GET /products/_search
{
  "query": { "match_all": {} },
  "size": 0,
  "aggs": {
    "by_brand": {
      "terms": { "field": "brand", "size": 20 }
    },
    "price_histogram": {
      "histogram": { "field": "price_cents", "interval": 5000 }
    },
    "over_time": {
      "date_histogram": { "field": "published_at", "calendar_interval": "month" }
    }
  }
}

Les agrégations nourrissent l'UI de filtres à facettes : "Sony (42)", "Bose (18)", histogramme de prix. PostgreSQL peut le faire mais pas sous 20 ms sur 10 millions de lignes.

Function score pour le relevance tuning

Boost personnalisé combinant pertinence textuelle et signaux métier.

{
  "query": {
    "function_score": {
      "query": { "multi_match": { "query": "casque", "fields": ["title", "description"] } },
      "functions": [
        { "filter": { "term": { "in_stock": true } }, "weight": 2 },
        { "field_value_factor": { "field": "rating_avg", "factor": 1.2, "missing": 1 } },
        { "gauss": { "published_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } }
      ],
      "score_mode": "multiply",
      "boost_mode": "multiply"
    }
  }
}

Ce template met en avant les produits en stock, bien notés, récemment publiés. Trois leviers métier qu'on ne saurait pas modéliser aussi simplement en SQL.

Reindex zero-downtime avec aliases

Les aliases sont la clé de la maintenance Elasticsearch sans coupure. Plutôt que d'écrire dans products, on écrit dans products-v1 et on expose products comme alias.

Procédure de reindex :

  1. Créer products-v2 avec le nouveau mapping.
  2. Réindexer tout le contenu (via _reindex API ou depuis la DB source).
  3. Basculer l'alias atomiquement : products pointe maintenant vers products-v2.
  4. Supprimer products-v1 après vérification.
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products-v1", "alias": "products" } },
    { "add":    { "index": "products-v2", "alias": "products" } }
  ]
}

L'action est atomique : aucun client ne voit un état intermédiaire. La bascule prend une milliseconde.

Snapshots et restauration

Elasticsearch intègre un système de snapshot vers S3 ou disque partagé. Backup incrémental, restauration rapide, versionné.

PUT /_snapshot/s3_repo
{
  "type": "s3",
  "settings": {
    "bucket": "ydh-es-backups",
    "region": "eu-west-1",
    "base_path": "prod"
  }
}

PUT /_snapshot/s3_repo/snapshot_2026_06_15?wait_for_completion=false
{
  "indices": "products,orders",
  "include_global_state": false
}

Nous planifions un snapshot quotidien via Curator, retention 30 jours, test de restauration trimestriel sur environnement staging. Un backup non testé est un backup qui n'existe pas.

Anti-patterns à fuir

Erreurs que nous voyons en audit avec un impact fort.

  • Elasticsearch comme source de vérité. Il n'a pas de transactions ACID. Toujours garder PostgreSQL (ou la source) comme master, Elasticsearch comme index dérivé.
  • Mapping dynamique en prod. Un champ inattendu crée un mapping erroné que seul un reindex complet corrige. Toujours "dynamic": "strict" ou "false".
  • Pas de lifecycle policy. Les index logs s'accumulent jusqu'à saturer le disque. ILM (Index Lifecycle Management) automatise la rotation.
  • Shards mal dimensionnés. 1 shard par index < 30 Go est la règle. 1000 shards sur un cluster, c'est ingérable.
  • Connexion directe depuis le browser. Jamais. Toujours passer par un backend qui filtre les tenants et construit les requêtes.
  • Pas de circuit breaker côté application. Une panne Elasticsearch fait cascader tous les endpoints qui l'utilisent si on ne met pas de timeout court et de fallback.

Coûts 2026

Estimations basées sur les déploiements que nous opérons.

Taille Volume Cluster Coût mensuel Elastic Cloud
Petite < 1M docs, < 10 RPS 1 node 2 Go ~50 EUR
Moyenne 1 à 10M docs, 50 RPS 1 node 8 Go ~100 EUR
Grande 10 à 100M docs, 500 RPS 3 nodes 16 Go ~800 EUR
Très grande > 100M docs, > 1000 RPS Dédié multi-régions à partir de 2500 EUR

Self-host sur VPS : diviser par 3 à 5, mais ajouter le coût d'exploitation (1 à 2 jours par mois pour un sysadmin senior sur un cluster de production).

Conclusion

Elasticsearch est un outil puissant mais exigeant. Déployé sans discipline (mapping dynamique, pas d'aliases, shards mal pensés), il devient rapidement un coût et une source de pannes. Déployé avec la méthode (mapping strict, aliases, bulk indexing, snapshots, ILM), il transforme la recherche d'une application PHP.

Pour un cadrage Elasticsearch sur votre projet, une refonte de recherche ou un audit d'un cluster existant, contactez-nous à contact@your-digital-hub.com. Voir aussi notre expertise PHP et notre service architecture logicielle.