YourBOX Search
Purpose
Section titled “Purpose”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.
Data Flow
Section titled “Data Flow”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 --> BrowserA 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.
Document Shape
Section titled “Document Shape”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.
Index Settings
Section titled “Index Settings”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.
Search Controller
Section titled “Search Controller”#[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.
Frontend Surface
Section titled “Frontend Surface”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.
Operational Limits And Sizing
Section titled “Operational Limits And Sizing”- 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.
Failure Modes And Recovery
Section titled “Failure Modes And Recovery”- 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.