Intégrer Typesense à Symfony 7 : une recherche instantanée, auto-hébergée et tolérante aux fautes

Intégrer Typesense à Symfony 7 : une recherche instantanée, auto-hébergée et tolérante aux fautes

Sur un blog, la recherche est souvent le parent pauvre de l'architecture. On commence par un bon vieux WHERE title LIKE '%terme%' en se disant qu'on fera mieux plus tard… et trois ans après, l'utilisateur qui tape « symphony » au lieu de « symfony » ne trouve toujours rien. Dans ce tutoriel, je vous propose de remplacer cette recherche MySQL approximative par un vrai moteur full-text : Typesense, auto-hébergé, intégré proprement à Symfony 7.4 sous PHP 8.4.

Objectif final : une autocomplétion de header (endpoint /api/search/autocomplete) instantanée, pertinente, tolérante aux fautes de frappe, et qui ne tombe jamais en panne grâce à un fallback MySQL automatique.

Prérequis et architecture cible

Avant de plonger dans le code, posons le décor :

  • Symfony 7.4 avec Doctrine ORM et une entité Article existante
  • PHP 8.4 (pour profiter des property hooks et des attributs modernes)
  • Docker / Docker Compose avec Traefik en reverse proxy
  • La librairie PHP officielle typesense/typesense-php ^6

Côté infrastructure, Typesense tourne en conteneur Docker (image typesense/typesense:27.1) sur le réseau interne de Docker. Une seule instance est partagée entre plusieurs sites, chacun isolé dans sa propre collection (par exemple jpsymfony_articles). Point crucial de sécurité : le port 8108 n'est jamais exposé publiquement. Symfony y accède en interne via http://typesense:8108, et la clé API ne quitte jamais le serveur.

Étape 1 : Typesense en conteneur Docker

On démarre par un service Docker minimal dans le docker-compose.yml :

services:
  typesense:
    image: typesense/typesense:27.1
    restart: unless-stopped
    environment:
      TYPESENSE_API_KEY: ${TYPESENSE_API_KEY}
      TYPESENSE_DATA_DIR: /data
      TYPESENSE_ENABLE_CORS: 'false'
    volumes:
      - typesense_data:/data
    networks:
      - web
    # Pas de section "ports:" — accès interne uniquement

volumes:
  typesense_data:

Notez l'absence volontaire de ports:. Tous les conteneurs du réseau web peuvent joindre typesense:8108, mais rien ne fuite vers l'extérieur.

Étape 2 : la factory de client Typesense

On installe la librairie :

composer require typesense/typesense-php:^6

Puis on configure les variables dans .env :

TYPESENSE_HOST=typesense
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=une-cle-tres-longue-et-secrete
TYPESENSE_COLLECTION=jpsymfony_articles

Le client Typesense est construit via une factory, ce qui isole sa configuration et facilite les tests :

<?php
namespace App\Search;

use Typesense\Client;

final class TypesenseClientFactory
{
    public function __construct(
        private string $host,
        private int $port,
        private string $protocol,
        private string $apiKey,
    ) {}

    public function create(): Client
    {
        return new Client([
            'api_key' => $this->apiKey,
            'nodes' => [[
                'host'     => $this->host,
                'port'     => $this->port,
                'protocol' => $this->protocol,
            ]],
            'connection_timeout_seconds' => 2,
        ]);
    }
}

On l'expose comme service Symfony avec la clé factory: dans config/services.yaml :

services:
    App\Search\TypesenseClientFactory:
        arguments:
            $host: '%env(TYPESENSE_HOST)%'
            $port: '%env(int:TYPESENSE_PORT)%'
            $protocol: '%env(TYPESENSE_PROTOCOL)%'
            $apiKey: '%env(TYPESENSE_API_KEY)%'

    Typesense\Client:
        factory: ['@App\Search\TypesenseClientFactory', 'create']

Désormais, n'importe quel service peut injecter Typesense\Client sans connaître sa construction.

Étape 3 : mapper l'entité vers un document

Typesense raisonne en documents JSON. Un objet dédié, ArticleDocument, transforme une entité Doctrine en payload prêt à indexer :

<?php
namespace App\Search;

use App\Entity\Article;

final class ArticleDocument
{
    public static function fromArticle(Article $article): array
    {
        return [
            'id'             => (string) $article->getId(),
            'title'          => $article->getTitle(),
            'excerpt'        => $article->getExcerpt() ?? '',
            'content'        => strip_tags($article->getContent() ?? ''),
            'category_slug'  => $article->getCategory()?->getSlug() ?? '',
            'tags'           => array_map(fn($t) => $t->getName(), $article->getTags()->toArray()),
            'slug'           => $article->getSlug(),
            'published_at'   => $article->getPublishedAt()?->getTimestamp() ?? 0,
        ];
    }

    public static function isIndexable(Article $article): bool
    {
        return $article->isPublished()
            && !$article->isInvisible()
            && !$article->isArchived();
    }
}

Trois remarques importantes : on nettoie le HTML avec strip_tags() pour ne pas polluer le score de pertinence, on convertit la date en timestamp (Typesense aime les types numériques pour le tri), et on centralise la règle d'indexation dans isIndexable().

Étape 4 : la commande de réindexation

La commande app:search:reindex détruit la collection, la recrée avec le bon schéma, puis ré-importe tous les articles indexables :

#[AsCommand(name: 'app:search:reindex')]
final class ReindexCommand extends Command
{
    public function __construct(
        private Client $typesense,
        private ArticleRepository $articles,
        private string $collectionName,
    ) { parent::__construct(); }

    protected function execute(InputInterface $in, OutputInterface $out): int
    {
        try {
            $this->typesense->collections[$this->collectionName]->delete();
        } catch (\Throwable) { /* n'existait pas */ }

        $this->typesense->collections->create([
            'name' => $this->collectionName,
            'fields' => [
                ['name' => 'title',         'type' => 'string'],
                ['name' => 'excerpt',       'type' => 'string'],
                ['name' => 'content',       'type' => 'string'],
                ['name' => 'category_slug', 'type' => 'string', 'facet' => true],
                ['name' => 'tags',          'type' => 'string[]', 'facet' => true],
                ['name' => 'slug',          'type' => 'string'],
                ['name' => 'published_at',  'type' => 'int64'],
            ],
            'default_sorting_field' => 'published_at',
            'token_separators' => ['-', '_', '/', '.'],
        ]);

        $docs = [];
        foreach ($this->articles->findAll() as $article) {
            if (ArticleDocument::isIndexable($article)) {
                $docs[] = ArticleDocument::fromArticle($article);
            }
        }

        $this->typesense->collections[$this->collectionName]
            ->documents->import($docs, ['action' => 'upsert']);

        $out->writeln(sprintf('<info>%d documents indexés</info>', count($docs)));
        return Command::SUCCESS;
    }
}

Les token_separators garantissent que « symfony-messenger » est cherchable autant que « symfony messenger ».

Étape 5 : indexation temps réel via Messenger

Réindexer toute la base à chaque modification serait absurde. On écoute donc les événements Doctrine via un EntityListener qui dispatche des messages :

#[AsEntityListener(event: Events::postPersist, entity: Article::class)]
#[AsEntityListener(event: Events::postUpdate,  entity: Article::class)]
#[AsEntityListener(event: Events::postRemove,  entity: Article::class)]
final class ArticleSearchListener
{
    public function __construct(private MessageBusInterface $bus) {}

    public function postPersist(Article $a): void { $this->bus->dispatch(new IndexArticle($a->getId())); }
    public function postUpdate(Article $a): void  { $this->bus->dispatch(new IndexArticle($a->getId())); }
    public function postRemove(Article $a): void  { $this->bus->dispatch(new DeindexArticle((string) $a->getId())); }
}

Le handler, lui, est blindé contre les pannes : si Typesense est injoignable, on log l'erreur mais on n'empêche surtout pas l'enregistrement de l'article.

#[AsMessageHandler]
final class IndexArticleHandler
{
    public function __construct(
        private Client $typesense,
        private ArticleRepository $repo,
        private LoggerInterface $logger,
        private string $collectionName,
    ) {}

    public function __invoke(IndexArticle $msg): void
    {
        try {
            $article = $this->repo->find($msg->id);
            if (!$article || !ArticleDocument::isIndexable($article)) {
                $this->typesense->collections[$this->collectionName]
                    ->documents[(string) $msg->id]->delete();
                return;
            }
            $this->typesense->collections[$this->collectionName]
                ->documents->upsert(ArticleDocument::fromArticle($article));
        } catch (\Throwable $e) {
            $this->logger->error('Typesense indexing failed', ['exception' => $e]);
        }
    }
}

Sync ou async ? Attention au piège

Voici la configuration que je recommande dans config/packages/messenger.yaml :

framework:
    messenger:
        transports:
            sync: 'sync://'
        routing:
            App\Message\IndexArticle: sync
            App\Message\DeindexArticle: sync

Pourquoi sync ? Parce que beaucoup d'équipes configurent un transport async (Redis, Doctrine, AMQP) sans déployer le worker correspondant. Résultat : les messages s'empilent dans une table, l'index ne se met jamais à jour, et personne ne s'en aperçoit avant des semaines. En mode sync, l'indexation est exécutée dans la même requête. Avec un Typesense local et un timeout court (2 s), l'impact est négligeable, et la cohérence est garantie. Vous pourrez basculer en async le jour où un worker supervisé tournera vraiment.

Étape 6 : le SearchService avec fallback MySQL

Le cœur de la résilience : on essaie Typesense, et si ça échoue, on retombe sur une recherche MySQL classique. L'utilisateur n'a jamais d'erreur 500.

final class SearchService
{
    public function __construct(
        private Client $typesense,
        private ArticleRepository $repo,
        private LoggerInterface $logger,
        private string $collectionName,
    ) {}

    public function autocomplete(string $query, int $limit = 8): array
    {
        try {
            $result = $this->typesense->collections[$this->collectionName]
                ->documents->search([
                    'q'                => $query,
                    'query_by'         => 'title,excerpt,content',
                    'query_by_weights' => '4,2,1',
                    'num_typos'        => 2,
                    'facet_by'         => 'category_slug',
                    'sort_by'          => '_text_match:desc,published_at:desc',
                    'per_page'         => $limit,
                ]);

            return array_map(fn($h) => $h['document'], $result['hits']);
        } catch (\Throwable $e) {
            $this->logger->warning('Typesense down, falling back to SQL', [
                'exception' => $e,
            ]);
            return $this->repo->searchLike($query, $limit);
        }
    }
}

Les query_by_weights donnent quatre fois plus de poids au titre qu'au contenu — exactement ce qu'on attend d'une autocomplétion. Le num_typos: 2 autorise deux fautes de frappe : « symfny » remontera bien « Symfony ».

Étape 7 : le contrôleur d'autocomplétion, rate-limité

On expose l'endpoint en gardant un contrat JSON identique à celui que le JavaScript front consomme déjà :

#[Route('/api/search/autocomplete', methods: ['GET'])]
public function autocomplete(
    Request $request,
    SearchService $search,
    RateLimiterFactory $searchLimiter,
): JsonResponse {
    $limiter = $searchLimiter->create($request->getClientIp());
    if (!$limiter->consume(1)->isAccepted()) {
        return new JsonResponse(['error' => 'Too many requests'], 429);
    }

    $q = trim((string) $request->query->get('q', ''));
    if (mb_strlen($q) < 2) {
        return new JsonResponse(['results' => []]);
    }

    return new JsonResponse(['results' => $search->autocomplete($q)]);
}

Étape 8 : un cron quotidien comme filet de sécurité

Même avec un EntityListener bien testé, des divergences finissent toujours par apparaître (imports SQL directs, restaurations de backup, etc.). Une réindexation complète quotidienne suffit à remettre tout en ordre :

0 4 * * * cd /var/www/app && php bin/console app:search:reindex --no-interaction

Conclusion et pistes d'approfondissement

En quelques services bien découpés, nous avons construit une recherche moderne : rapide grâce à Typesense, tolérante aux fautes via num_typos, résiliente grâce au fallback MySQL, et sécurisée car la clé API ne quitte jamais le serveur. Chaque pièce respecte les bonnes pratiques Symfony : injection de dépendances, factory de service, Messenger, EntityListener Doctrine, rate-limiter.

Pour aller plus loin, plusieurs pistes méritent l'exploration :

  • Synonymes : déclarer « JS » ↔ « JavaScript » directement dans Typesense
  • Scoped Search Keys : générer des clés API temporaires côté serveur pour interroger Typesense directement depuis le navigateur, sans relais Symfony
  • Highlight : utiliser le champ highlights de la réponse Typesense pour surligner les termes trouvés
  • Facettes UI : afficher les compteurs par catégorie et par tag dans une page de recherche avancée

Vous avez maintenant une base solide pour transformer la recherche de votre application Symfony en véritable atout produit. Bonne indexation !

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *