Le problème de l'indexation synchrone

L'approche naïve : appeler $client->index('products')->addDocuments([$doc]) directement dans le handler de votre commande. Ça fonctionne en dev, ça explose en prod.

Meilisearch est rapide, mais chaque appel HTTP sur le chemin critique d'une écriture ajoute de la latence. Pire : si Meilisearch est temporairement indisponible, votre écriture en base échoue aussi — ou vous perdez silencieusement la synchronisation.

Piège classique

Appeler Meilisearch dans un postPersist Doctrine ou dans un controller synchronise l'index mais couple votre disponibilité à celle du moteur de recherche. Un timeout Meilisearch = une requête utilisateur en erreur.

Architecture event-driven avec Symfony Messenger

La solution : décorréler l'écriture en base de l'indexation via un bus de messages. Le domaine publie un événement, un worker asynchrone se charge de l'indexation. Si Meilisearch est down, le message est retenté automatiquement.

PHP src/Product/Domain/Event/ProductUpdated.php
final readonly class ProductUpdated
{
    public function __construct(
        public readonly ProductId $productId,
    ) {}
}

// Dans votre CommandHandler, après flush :
$this->bus->dispatch(new ProductUpdated($product->getId()));

L'événement ne transporte que l'identifiant — pas l'entité entière. Le handler rechargera les données fraîches depuis la base au moment du traitement.

YAML config/packages/messenger.yaml
framework:
  messenger:
    transports:
      search_index:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          queue_name: search_index
        retry_strategy:
          max_retries: 5
          delay: 2000
          multiplier: 2   # backoff exponentiel

    routing:
      'App\Product\Domain\Event\ProductUpdated': search_index
      'App\Product\Domain\Event\ProductDeleted': search_index

Le handler d'indexation

Le handler recharge l'entité depuis la base, construit le document Meilisearch et envoie la mise à jour. En cas d'exception, Messenger retentera automatiquement selon la stratégie configurée.

PHP src/Product/Infrastructure/Search/IndexProductHandler.php
final class IndexProductHandler
{
    public function __construct(
        private readonly ProductRepository $products,
        private readonly Client            $meilisearch,
    ) {}

    #[AsMessageHandler]
    public function __invoke(ProductUpdated $event): void
    {
        $product = $this->products->findOrFail($event->productId);

        $this->meilisearch
            ->index('products')
            ->addDocuments([$this->toDocument($product)]);
    }

    private function toDocument(Product $p): array
    {
        return [
            'id'          => (string) $p->getId(),
            'name'        => $p->getName(),
            'description' => $p->getDescription(),
            'price'       => $p->getPrice()->getAmount(),
            'tags'        => $p->getTags(),
            'updated_at'  => $p->getUpdatedAt()->getTimestamp(),
        ];
    }
}

Bonne pratique

Rechargez toujours l'entité dans le handler, jamais depuis le message. Un message peut rester en queue plusieurs secondes — les données sérialisées seraient déjà obsolètes au moment du traitement.

Réindexation incrémentale sans downtime

Tôt ou tard, vous devrez réindexer massivement : changement de mapping, ajout d'un champ, correction de données. La stratégie naïve — vider l'index et tout réimporter — crée une fenêtre pendant laquelle la recherche renvoie zéro résultat. Inacceptable en production.

La solution : le pattern blue/green d'index. On écrit dans un index temporaire en parallèle, puis on bascule l'alias atomiquement.

PHP src/Search/ReindexCommand.php
#[AsCommand(name: 'search:reindex')]
final class ReindexCommand extends Command
{
    protected function execute(InputInterface $in, OutputInterface $out): int
    {
        $tmpIndex = 'products_' . date('Ymd_His'); // ex: products_20260228_141500

        // 1. Créer l'index temporaire avec le bon mapping
        $this->createIndex($tmpIndex);

        // 2. Importer toutes les données en batch
        foreach ($this->products->iterateBatches(500) as $batch) {
            $this->meilisearch->index($tmpIndex)->addDocuments($batch);
        }

        // 3. Attendre la fin de l'indexation Meilisearch
        $this->waitForIndexing($tmpIndex);

        // 4. Swap atomique : l'alias "products" pointe maintenant sur le nouvel index
        $this->swapAlias('products', $tmpIndex);

        return Command::SUCCESS;
    }
}

Pièges à éviter

Déduplication des messages

Si un produit est modifié 10 fois en 1 seconde, 10 messages seront dispatchés. Chaque traitement est idempotent côté Meilisearch, mais c'est du gaspillage. Utilisez un debounce en enregistrant un seul message par entité dans une fenêtre de temps, ou filtrez les doublons dans le middleware.

Ordre des messages non garanti

Avec plusieurs workers en parallèle, un ProductDeleted peut être traité avant le ProductUpdated qui l'a précédé en base. Stockez un updated_at dans le message et ignorez les mises à jour antérieures à la version déjà indexée.

Note

Meilisearch est eventually consistent par rapport à votre base de données. C'est acceptable pour 99% des cas de recherche, mais documentez ce choix si votre métier exige une cohérence stricte.

Conclusion

Le résultat de cette architecture : les écritures métier ne dépendent plus de la disponibilité de Meilisearch, la réindexation complète est possible sans interruption de service, et les retries automatiques de Messenger absorbent les pics de charge et les pannes transitoires.

L'indexation asynchrone n'est pas une optimisation prématurée — c'est le seul moyen de tenir un SLA correct quand la recherche n'est pas le chemin critique de vos écritures.