Meilisearch + Symfony : implémenter une recherche rapide et efficace

Meilisearch et Symfony

Introduction

Dans un précédent article, nous avons vu comment indexer nos données avec Meilisearch en utilisant sa version Docker, nous permettant ainsi de garder un contrôle total sur notre base d’informations.

Aujourd’hui, allons plus loin ! Nous allons mettre en place une implémentation simple et rapide pour tester l’efficacité de Meilisearch face à une requête classique en base de données.

💡 L’objectif ? Créer un Proof of Concept (POC) clair et structuré, tout en respectant les bonnes pratiques de clean code.

🔹 Ce que nous allons faire :
✅ Créer deux routes et un contrôleur dédié pour gérer la recherche.
✅ Implémenter une barre de recherche classique connectée à notre base de données.
✅ Comparer cette approche avec une intégration Meilisearch, pour mesurer les gains en rapidité et pertinence.

Tout cela en local, avec Docker, dans un environnement de développement. 🚀

Symfony UX et Twig Live component

Vous le savez, j’ai un faible pour les paquets UX de Symfony. Ils offrent une implémentation rapide, une sécurité renforcée et une scalabilité intéressante (si tant est qu’on aime le PHP 😏).

Dans notre cas, nous allons utiliser Twig Live Component pour créer une barre de recherche dynamique, qui réagit instantanément aux entrées utilisateur. Ce choix permet de préserver la sécurité (grâce aux CSRF tokens générés automatiquement) tout en assurant une intégration propre avec Meilisearch.

Commençons par installer le package via Composer :

composer require symfony/ux-live-component

Modification du Twig du composant

Ensuite, il suffit de modifier le template Twig de notre Live Component pour gérer l’affichage dynamique des résultats de recherche.

<form {{ attributes }} role="search">
    <div class="grid">
        <input
                type="search"
                data-model="query"
                placeholder="Rechercher..."
                aria-label="Rechercher des articles"
        >
    </div>

    <div class="container">
        <div class="list">
            {% for article in this.articles %}
                <article class="article-card">
                    <div class="article-content">
                        {% if article.image %}
                            <img src="{{ article.image }}" alt="{{ article.title }}" class="img-card" />
                        {% endif %}
                        <h3>{{ article.title }}</h3>
                        {% if article.description %}
                            <p>{{ article.description }}</p>
                        {% endif %}
                        <a href="{{ path('app_article_show', {'id': article.id, 'title': article.title})}}" role="button" class="secondary">
                            Voir l'article
                        </a>
                    </div>
                </article>
            {% else %}
                <div class="empty-results">
                    <p>Aucun article ne correspond à votre recherche.</p>
                </div>
            {% endfor %}
        </div>
    </div>
</form>

Sans oublier la class PHP

<?php

namespace App\Twig\Components;

use App\Repository\ArticleRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
final class SearchBar
{
    use DefaultActionTrait;

    #[LiveProp(writable: true, url: true)]
    public ?string $query = null;

    public function __construct(private readonly ArticleRepository $articleRepository)
    {
    }

    public function getArticles(): array
    {
        return $this->articleRepository->search($this->query);
    }
}

Enfin, nous chargeons ce Live Component directement dans notre page, ou même globalement si nécessaire.
Toutefois, dans ce POC, j’ai volontairement choisi de séparer l’implémentation Meilisearch de la recherche traditionnelle en base de données pour mieux comparer les performances.

Et c’est tout ! Dès qu’on tape dans la barre de recherche, les résultats apparaissent dynamiquement, avec un CSRF token par requête. Un vrai petit bijou de sécurité et de simplicité. 🚀

Meilisearch, la puissance pour les gros volumes

Jusqu’ici, notre recherche repose sur une simple requête SQL. Efficace ? Oui. Scalable ? Pas vraiment.

Quand le volume de données est faible, une barre de recherche classique (comme celle faite avec Twig Live Component) suffit largement. Mais dès qu’on passe sur des milliers voire des millions d’entrées, la performance et la rapidité d’indexation deviennent critiques.

💡 Pourquoi Meilisearch ?
✅ Ultra-rapide grâce à son indexation avancée
✅ Optimisé pour la recherche full-text
✅ Facile à mettre en place avec Docker
✅ Expérience utilisateur fluide, sans latence

Dans un article précédent, nous avons vu comment créer un index Meilisearch et modifier notre docker-compose.yml pour lancer une instance locale.

👉 Partons du principe que tout est fonctionnel. Vous avez déjà indexé vos données, et Meilisearch tourne sur votre machine.

Mais maintenant ? Comment interroger Meilisearch depuis Symfony ? C’est ce qu’on va voir tout de suite ! 🚀

Création d’un contrôleur dédié à Meilisearch

class SearchController extends AbstractController
{
    private Client $client;
    private string $prefix;

    public function __construct(ParameterBagInterface $parameterBag)
    {
        $url = $parameterBag->get('app.meilisearch.url');
        $apiKey = $parameterBag->get('app.meilisearch.api_key');
        $this->prefix = $parameterBag->get('app.meilisearch.prefix');

        $this->client = new Client($url, $apiKey);
    }
    
    #[Route('/app/meilisearch/search', name: 'app_search')]
    public function search(): Response
    {
        return $this->render('search/index.html.twig');
    }
    
     #[Route('/meilisearch/search/api', name: 'app_meilisearch_search_api')]
    public function searchApi(Request $request): Response
    {
        $query = $request->query->get('q', '');
        $type = $request->query->get('type', null);
        $page = max(1, (int)$request->query->get('page', 1));
        $limit = 12;
        $offset = ($page - 1) * $limit;

        $searchOptions = [
            'limit' => $limit,
            'offset' => $offset
        ];

        $results = [];

        try {
            if (!empty($query)) {
                if ($type === 'products') {
                    $index = $this->client->index($this->prefix . 'products');
                    $searchResults = $index->search($query, $searchOptions);
                    $results = [
                        'hits' => $searchResults->getHits(),
                        'totalHits' => $searchResults->getEstimatedTotalHits(),
                        'totalPages' => ceil($searchResults->getEstimatedTotalHits() / $limit),
                        'currentPage' => $page
                    ];
                } elseif ($type === 'articles') {
                    $index = $this->client->index($this->prefix . 'articles');
                    $searchResults = $index->search($query, $searchOptions);
                    $results = [
                        'hits' => $searchResults->getHits(),
                        'totalHits' => $searchResults->getEstimatedTotalHits(),
                        'totalPages' => ceil($searchResults->getEstimatedTotalHits() / $limit),
                        'currentPage' => $page
                    ];
                } else {
                    // Recherche globale
                    $productIndex = $this->client->index($this->prefix . 'products');
                    $productResults = $productIndex->search($query, $searchOptions);

                    $articleIndex = $this->client->index($this->prefix . 'articles');
                    $articleResults = $articleIndex->search($query, $searchOptions);

                    $results = [
                        'products' => $productResults->getHits(),
                        'articles' => $articleResults->getHits(),
                        'totalProductHits' => $productResults->getEstimatedTotalHits(),
                        'totalArticleHits' => $articleResults->getEstimatedTotalHits(),
                        'totalHits' => $productResults->getEstimatedTotalHits() + $articleResults->getEstimatedTotalHits(),
                        'currentPage' => $page
                    ];
                }
            }

            return $this->json($results);
        } catch (\Exception $e) {
            return $this->json([
                'error' => $e->getMessage()
            ], 500);
        }
    }

Configuration de Meilisearch dans services.yaml

On doit déclarer les params de Meilisearch dans config/services.yaml

parameters:
    app.meilisearch.url: '%env(MEILISEARCH_URL)%'
    app.meilisearch.api_key: '%env(MEILISEARCH_API_KEY)%'
    app.meilisearch.prefix: '%env(MEILISEARCH_PREFIX)%'

Le javascript pour l’implémentation dans le twig

        document.addEventListener('DOMContentLoaded', function() {
            const searchInput = document.getElementById('search-input');
            const searchType = document.getElementById('search-type');
            const searchResults = document.getElementById('search-results');
            const searchLoader = document.getElementById('search-loader');
            const debounceTimeout = 300; // Délai en ms avant de lancer la recherche
            let timeoutId;

            // Fonction pour effectuer la recherche
            function performSearch() {
                const query = searchInput.value.trim();
                const type = searchType.value;

                if (query.length < 2) {
                    searchResults.innerHTML = '';
                    return;
                }

                // Afficher le loader
                searchLoader.classList.remove('d-none');

                // Appel à l'API
                fetch(`{{ path('app_meilisearch_search_api') }}?q=${encodeURIComponent(query)}&type=${encodeURIComponent(type)}`)


                    .then(response => {
                        // Vérifier si la réponse est OK avant de parser le JSON
                        if (!response.ok) {
                            throw new Error('Erreur réseau: ' + response.status);
                        }
                        return response.json();
                    })
                    .then(data => {
                        console.log('API Response:', data); // Log pour débugger

                        // Mise à jour de l'URL sans recharger la page
                        const url = new URL(window.location);
                        url.searchParams.set('q', query);
                        url.searchParams.set('type', type);
                        window.history.pushState({}, '', url);

                        // Cacher le loader
                        searchLoader.classList.add('d-none');

                        // Si la réponse a une structure avec "hits" (comme dans votre JSON exemple)
                        let processedData = data;

                        if (data.hits) {
                            // Convertir le format de "hits" au format attendu par displayResults
                            if (type === 'products' || type === '') {
                                processedData = {
                                    products: data.hits || [],
                                    articles: []
                                };
                            } else if (type === 'articles') {
                                processedData = {
                                    products: [],
                                    articles: data.hits || []
                                };
                            }
                        }

                        // Afficher les résultats
                        displayResults(processedData, query);
                    })
                    .catch(error => {
                        console.error('Erreur lors de la recherche:', error);
                        searchLoader.classList.add('d-none');
                        searchResults.innerHTML = `<div class="alert alert-danger">Une erreur est survenue: ${error.message}</div>`;
                    });
            }

            // Fonction pour afficher les résultats
            function displayResults(data, query) {
                console.log('Processed data for display:', data); // Log pour débugger

                let html = '<div class="row">';

                // Vérifier si nous avons des résultats pour les produits
                if (data.products && data.products.length > 0) {
                    html += `<div class="col-md-12 mb-4">
                <h2>Produits (${data.products.length})</h2>
                <div class="row">`;

                    data.products.forEach(product => {
                        // Image par défaut si non disponible
                        const imageSrc = product.image || '/images/default-product.jpg';
                        const productName = product.name || 'Produit sans nom';
                        const productPrice = product.price ? parseFloat(product.price).toLocaleString('fr-FR', {
                            minimumFractionDigits: 2,
                            maximumFractionDigits: 2
                        }) : '0,00';
                        const productDesc = product.Description ? product.Description.slice(0, 100) + '...' : '';
                        const productCategory = product.category && product.category.name
                            ? `<p class="card-text"><small class="text-muted">Catégorie: ${product.category.name}</small></p>`
                            : '';

                        html += `<article class="col-md-4 mb-3">
                    <div class="card h-100">
                        <div class="card-body">
                            <img src="${imageSrc}" alt="${productName}" class="img-fluid mb-2">
                            <h5 class="card-title">${productName}</h5>
                            <h6 class="card-subtitle mb-2 text-muted">${productPrice} €</h6>
                            <p class="card-text">${productDesc}</p>
                            ${productCategory}
                            <a href="/product/${product.id}" class="btn btn-sm btn-primary">Voir détails</a>
                        </div>
                    </div>
                </article>`;
                    });

                    html += `</div></div>`;
                }

                // Vérifier si nous avons des résultats pour les articles
                if (data.articles && data.articles.length > 0) {
                    html += `<div class="col-md-12 mb-4">
                <h2>Articles (${data.articles.length})</h2>
                <div class="list-group">`;

                    data.articles.forEach(article => {
                        // Image par défaut si non disponible
                        const imageSrc = article.image || '/images/default-article.jpg';
                        const articleTitle = article.title || 'Article sans titre';
                        const articleDesc = article.description ? article.description.slice(0, 150) + '...' : '';

                        html += `<a href="/article/${article.id}/title/${encodeURIComponent(articleTitle)}" class="list-group-item list-group-item-action">
                    <article class="d-flex w-100 justify-content-between">
                        <div class="me-3" style="width: 100px;">
                            <img src="${imageSrc}" alt="${articleTitle}" class="img-fluid">
                        </div>
                        <div class="flex-grow-1">
                            <h5 class="mb-1">${articleTitle}</h5>
                            <p class="mb-1">${articleDesc}</p>
                        </div>
                    </article>
                </a>`;
                    });

                    html += `</div></div>`;
                }

                // Si aucun résultat
                if ((!data.products || data.products.length === 0) &&
                    (!data.articles || data.articles.length === 0)) {
                    html += `<div class="alert alert-info">Aucun résultat trouvé pour "${query}"${searchType.value ? ` dans ${searchType.options[searchType.selectedIndex].text.toLowerCase()}` : ''}</div>`;
                }

                html += '</div>';
                searchResults.innerHTML = html;
            }

            // Écouteurs d'événements
            searchInput.addEventListener('input', function() {
                clearTimeout(timeoutId);
                timeoutId = setTimeout(performSearch, debounceTimeout);
            });

            searchType.addEventListener('change', performSearch);

            // Lancer une recherche initiale si le champ a déjà une valeur
            if (searchInput.value.trim().length > 0) {
                performSearch();
            }
        });

Résultat : une recherche en temps réel !

💡 On voit bien que ce n’est pas si complexe !
Finalement, cette approche ressemble fortement à notre Live Component, mais avec Meilisearch en backend pour des performances inégalées.

🚀 Vous tapez, les résultats s’affichent instantanément.
🔍 Meilisearch gère la recherche full-text avec une précision incroyable

Meilisearch offre une excellente DX pour Symfony 7

Comprendre les Hits et la pagination automatique de Meilisearch

Lors du développement, j’ai rencontré un souci avec mon implémentation JavaScript. Après quelques recherches, j’ai compris que Meilisearch ne retourne pas simplement une liste brute de résultats, mais qu’il fonctionne avec un système de Hits et de pagination automatique.

💡 Qu’est-ce qu’un Hit ?
Un Hit représente un résultat individuel retourné par Meilisearch. Contrairement à une requête SQL classique qui retourne un tableau de lignes, Meilisearch encapsule les résultats sous une structure optimisée pour la recherche full-text.

Voici un exemple de réponse JSON typique d’une requête Meilisearch :

return api de meilisearch

Pourquoi c’est important ?

👉 Meilisearch gère la pagination nativement grâce aux paramètres offset et limit.
👉 Pas besoin d’écrire une logique complexe pour gérer les pages, c’est Plug & Play.
👉 Cela optimise la performance, surtout sur de gros volumes de données.

Avec ce fonctionnement, Meilisearch devient incontournable pour gérer de grandes bases de données avec un temps de réponse ultra-rapide.

✅ Recherche instantanée
✅ Gestion automatique de la pagination
✅ Scalabilité assurée

Si vous avez beaucoup de données et que vous voulez une recherche performante, Meilisearch est un choix évident.

Conclusion

Après avoir comparé une barre de recherche classique et Meilisearch, le verdict est sans appel : la différence de vitesse est impressionnante !

⚡ Un temps de réponse inférieur à 1ms, même en local.
⚡ Une UX instantanée, sans latence.
⚡ Aucune sollicitation directe de la base de données, donc des performances accrues.

Mais attention ⚠️

👉 Meilisearch est une instance supplémentaire à gérer.
👉 Il faut anticiper le coût en ressources et la maintenance.

Pourquoi l’adopter ?

✅ Idéal pour gérer d’énormes volumes de données
✅ Scalabilité quasi illimitée
✅ Recherche full-text avancée et pagination native

🚀 Il ne vous reste plus qu’à tester avec de gros volumes et à apprécier toute la puissance de Meilisearch !

Cet article t’a plu ?

Ce Proof of Concept montre bien la puissance de Meilisearch couplée à Symfony. La vitesse de recherche est bluffante, l’UX est instantanée, et la scalabilité est au rendez-vous.

🔗 Le code source est disponible sur GitHub 

Et la suite ? 🚀

Dans un prochain article, nous irons encore plus loin :
✅ Mise à jour automatique de l’index
✅ Optimisation avancée des requêtes
✅ Peut-être même une touche d’IA pour booster la recherche ?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *