Skip to content

YourBOX Search

ADR-0003 replaces the legacy LIKE %x% search and its on disk HTML cache with Meilisearch. PostgreSQL remains the source of truth for video metadata. Meilisearch holds a denormalized projection refreshed by a Symfony Messenger consumer. This page documents the projection, the index settings, the search controller and the operational paths for reindexing.

flowchart LR
Catalog[Catalog UI] -- write --> PG[(PostgreSQL videos)]
PG -- domain event --> Bus[Symfony Messenger]
Bus -- VideoIndexerHandler --> Meili[(Meilisearch yourbox-videos)]
Browser -- GET /search --> SearchCtrl[SearchController]
SearchCtrl -- query --> Meili
Meili -- hits --> SearchCtrl
SearchCtrl -- render --> Browser

A VideoPublished, VideoUpdated or VideoDeleted event triggers a message handled by App\Catalog\Infrastructure\Search\VideoIndexerHandler. The handler reads the canonical document from PostgreSQL and upserts or deletes it in the Meilisearch index.

The projection is eventually consistent. Under normal load the lag is below one second. The runbook documents how to detect and recover from a stalled consumer.

The index yourbox-videos stores one document per published video:

{
"id": "01H8XGXG48HJ7TKMQJ4F2VPN0Y",
"title": "End Of Everything",
"description": "Very great music with hand pan",
"tags": ["music", "handpan", "instrumental"],
"creator": "Didi Chandouidoui",
"main_category": "Music",
"uploaded_by_username": "thonyan32",
"uploaded_by_user_id": "01H7QZ1XYBCQ9P3R5M0AVR2DJC",
"duration_seconds": 433,
"published_at": 1716045600,
"thumbnail_key": "videos/2026/06/01H8XGXG48HJ7TKMQJ4F2VPN0Y/thumbnail.jpg",
"manifest_key": "videos/2026/06/01H8XGXG48HJ7TKMQJ4F2VPN0Y/hls/index.m3u8",
"view_count": 44,
"like_count": 0,
"dislike_count": 0
}

The primary key is the video ULID. Numeric counters are denormalized into the document so that ranking and faceting work without joining back to PostgreSQL.

The settings are committed to the repository under app/config/meilisearch/settings.json and applied through a Symfony console command documented on the reindex runbook.

{
"searchableAttributes": [
"title",
"tags",
"description",
"creator",
"main_category",
"uploaded_by_username"
],
"displayedAttributes": [
"id", "title", "description", "tags", "main_category",
"creator", "duration_seconds", "thumbnail_key", "manifest_key",
"uploaded_by_username", "view_count", "like_count", "published_at"
],
"filterableAttributes": [
"main_category", "tags", "uploaded_by_user_id", "published_at"
],
"sortableAttributes": [
"published_at", "view_count", "like_count", "duration_seconds"
],
"rankingRules": [
"words",
"typo",
"proximity",
"attribute",
"exactness",
"view_count:desc",
"published_at:desc"
],
"typoTolerance": {
"enabled": true,
"minWordSizeForTypos": { "oneTypo": 4, "twoTypos": 8 }
},
"stopWords": ["the", "a", "an", "and", "or", "of"],
"synonyms": {
"vid": ["video"],
"clip": ["video"]
}
}

The ranking rules weight Meilisearch’s relevance first, then nudge ties by view count and recency. The view count is refreshed on each VideoViewed event with a batched upsert (the consumer flushes every 5 seconds or every 50 events) to avoid hammering the index on popular videos.

#[Route('/search', name: 'catalog_search', methods: ['GET'])]
public function __invoke(
Request $request,
QueryBus $queryBus,
): Response {
$query = new SearchCatalog(
keywords: trim((string) $request->query->get('q', '')),
category: $request->query->get('category'),
page: max(1, $request->query->getInt('page', 1)),
perPage: 24,
);
$envelope = $queryBus->dispatch($query);
$result = $envelope->last(HandledStamp::class)->getResult();
return $this->render('catalog/search.html.twig', [
'query' => $query,
'hits' => $result->hits,
'pagination' => $result->pagination,
'facets' => $result->facets,
]);
}

The SearchCatalog query handler calls the Meilisearch SDK with the configured filters, asks for the main_category and tags facet distributions, paginates by ULID rather than offset for the infinite scroll view, and returns a typed SearchResult value object.

The search page uses Symfony UX Turbo Streams for partial updates. Typing in the search box debounces 250 ms and submits through TurboFrame, which replaces the results list without reloading the page. The Twig template lives at app/templates/catalog/search.html.twig and is styled with the Tailwind tokens documented on the Frontend page.

The legacy searching/answer_request.php and the cache/searchings/ HTML cache are removed once the search migration step lands per the Symfony Migration page.

  • The Meilisearch instance runs as a single node in every environment. The expected document count for the foreseeable future is under one million; a single node handles this comfortably. Sharding is not configured and would require Meilisearch Cloud or a custom multi index layout.
  • The index is sized for around 50 MiB on disk per 10 000 videos with the document shape above. The Helm chart provisions a 5 GiB PersistentVolumeClaim by default.
  • A reindex from scratch on 100 000 documents takes around 5 minutes. The reindex runbook documents the exact procedure with the corresponding Symfony console command.
  • Stalled consumer: the Messenger transport has a Redis backend in production (Symfony Messenger via Redis). A consumer crash triggers a Kubernetes restart. The message stays on the stream until acknowledged. The runbook describes how to inspect and replay messages.
  • Drift between PostgreSQL and Meilisearch: a daily reconciliation Symfony command compares document counts and a sampled hash of recent documents. A mismatch above the threshold triggers a Prometheus alert documented on the Observability page.
  • Meilisearch downtime: the search controller catches connection errors and serves a degraded results page with a banner. The catalog browse, upload and playback paths do not depend on Meilisearch and stay available.