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.
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.
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.
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.
#[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.