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

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 :

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