La recherche interne est le talon d'Achille du web moderne. En 2026, l'utilisateur a été conditionné par des années d'instantanéité : il ne cherche pas, il trouve.
Pourtant, la majorité des plateformes techniques continuent de s'appuyer sur des architectures archaïques, où une simple faute de frappe mène à une impasse et où la latence se compte en secondes. C'est une dette technique invisible qui dégrade silencieusement l'expérience utilisateur.
Sur mon site, j'ai pris le parti de traiter la recherche non pas comme une fonctionnalité annexe, mais comme le coeur de la navigation. L'objectif était clair : atteindre une faible latence et offrir une tolérance totale aux erreurs syntaxiques.
Pour y parvenir sans déployer une infrastructure lourde de type ElasticSearch, mon choix s'est porté sur Meilisearch.
Au-delà du SQL
L'approche traditionnelle — une requête SQL LIKE via Doctrine — atteint rapidement ses limites structurelles. Elle souffre d'un triple handicap : performance, rigidité et absence de pertinence sémantique.
J'ai brièvement évalué les solutions tierces comme Google Programmable Search. Le verdict est sans appel : déléguer la recherche, c'est diluer son identité visuelle et compromettre la confidentialité des données.
Meilisearch s'impose alors comme la solution de référence pour les architectures modernes. Écrit en Rust, il offre une empreinte mémoire minimale et une API typée qui s'intègre naturellement aux standards de développement actuels.
L'abstraction comme standard
L'erreur classique consiste à coupler le moteur de recherche à une entité spécifique. Dans une vision à long terme, l'infrastructure doit rester agnostique.
J'ai donc introduit une interface stricte, SearchableContentInterface, qui agit comme un contrat universel pour tout contenu indexable. Qu'il s'agisse d'un article, d'une page de documentation ou d'un snippet de code, le moteur de recherche ne voit qu'une projection standardisée des données.
interface SearchableContentInterface
{
public function getId(): ?Uuid;
public function getSearchableTitle(): string;
public function getSearchableContent(): string; // Cleaned, raw text
public function getSearchableType(): string; // 'post', 'page', 'snippet'
public function getSearchableAttributes(): array;
}
C'est une application directe du principe de ségrégation des interfaces (ISP) : le service d'indexation consomme un comportement, pas une implémentation.
Résilience et dégradation gracieuse
Dans une architecture distribuée, la panne n'est pas une anomalie, c'est un état possible. Si le conteneur Meilisearch ne répond plus, l'interface utilisateur ne doit pas s'effondrer.
J'ai conçu le ResilientSearchService autour d'un pattern de proxy défensif. Il encapsule l'appel au moteur de recherche dans un bloc de contrôle qui, en cas d'échec critique (timeout, erreur réseau), bascule silencieusement vers une implémentation SQL LIKE.
#[AsAlias(SearchServiceInterface::class)]
final readonly class ResilientSearchService implements SearchServiceInterface
{
public function __construct(
private Client $meilisearch,
private SqlSearchService $sqlFallback,
private LoggerInterface $logger
) {}
public function search(string $query, int $limit = 10): SearchResultCollection
{
try {
return $this->searchWithMeilisearch($query, $limit);
} catch (ApiException | CommunicationException $e) {
$this->logger->critical('Search infrastructure failure. Fallback activated.', [
'error' => $e->getMessage()
]);
return $this->sqlFallback->search($query, $limit);
}
}
}
Ce mécanisme garantit la continuité de service. L'expérience est dégradée (perte de la tolérance aux fautes), mais la fonctionnalité reste opérationnelle.
Configuration
Un moteur de recherche est rarement pertinent pour un domaine spécialisé. Toute la valeur réside dans la configuration de l'index.
Via le MeilisearchIndexConfigurator, j'injecte une couche de synonymes techniques qui agit comme un dictionnaire de traduction pour développeurs. Taper "sf" renvoie les résultats "Symfony" ; "regex" mappe vers "Expression Régulière".
En parallèle, l'application d'une liste de mots vides drastique permet de nettoyer le bruit sémantique. Les prépositions et articles sont éliminés avant même l'indexation, optimisant ainsi la densité pertinence/octet de l'index.
Synchronisation asynchrone
La mise à jour de l'index pose un défi de performance : l'indexation synchrone ralentit l'écriture en base de données.
La réponse réside dans Symfony Messenger. J'utilise les événements du cycle de vie Doctrine (postPersist, postUpdate) non pas pour indexer, mais pour dispatcher un message léger.
#[AsDoctrineListener(event: Events::postPersist)]
final class ContentChangeDoctrineSubscriber
{
public function __construct(private MessageBusInterface $bus) {}
public function postPersist(PostLegacyEvent $event): void
{
$this->bus->dispatch(new IndexContentMessage(
$event->getEntity()->getId(),
get_class($event->getEntity())
));
}
}
Ce découplage est total. Le worker traite l'indexation en arrière-plan, avec une politique de réessai automatique propre à Messenger.
Interface
Le frontend, propulsé par Symfony UX LiveComponent, élimine la complexité d'un framework JS complet tout en conservant la réactivité.
L'expérience est construite autour de deux états clés :
- L'attente active : L'utilisation de squelettes de chargement (Skeleton UI) préserve la structure visuelle de la page, éliminant tout Layout Shift.
- La découverte contextuelle : Le surlignage (
<mark>) des termes trouvés offre un feedback immédiat sur la pertinence du résultat.
Mais le véritable atout UX réside dans la gestion du zéro Résultat. Plutôt que de laisser l'utilisateur face au vide, le système bascule en mode "Sérendipité", suggérant des contenus techniques connexes ou analysant l'URL d'une page 404 pour en extraire des mots-clés probables.
Le mot de la fin
L'intégration de Meilisearch dépasse la simple commodité technique. C'est une brique fondamentale qui transforme une base de connaissances statique en un système d'information vivant.
En combinant la performance brute du Rust, la rigueur architecturale de Symfony et une couche de résilience pragmatique, on obtient une solution qui ne se contente pas de répondre aux requêtes : elle anticipe l'intention de l'utilisateur.