get('forum.index.counts.v2', function (ItemInterface $item) use ($index) { // $item->expiresAfter(30); // 30s is a nice compromise for “live enough” $allTags = $this->flattenAllTags(ForumTopics::TOPICS); // ['tag' => true, ...] $counts = $this->fetchTagCounts($index, array_keys($allTags)); // ['tag' => count] // return $this->hydrateCategoryCounts(self::TOPICS, $counts); //}); $categoriesWithCounts = $this->hydrateCategoryCounts(ForumTopics::TOPICS, $counts); return $this->render('forum/index.html.twig', [ 'topics' => $categoriesWithCounts, ]); } #[Route('/forum/topic/{key}', name: 'forum_topic')] public function topic( string $key, #[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, #[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, Request $request ): Response { // key format: "{category}-{subcategory}" $key = strtolower(trim($key)); [$cat, $sub] = array_pad(explode('-', $key, 2), 2, null); if (!$cat || !$sub || !isset(ForumTopics::TOPICS[$cat]['subcategories'][$sub])) { throw $this->createNotFoundException('Topic not found'); } $topic = ForumTopics::TOPICS[$cat]['subcategories'][$sub]; // Count each tag in this subcategory in one shot $tags = array_map('strval', $topic['tags']); $tagCounts = $this->fetchTagCounts($index, $tags); // Fetch articles for the topic $bool = new BoolQuery(); $bool->addFilter(new Terms('topics', $tags)); $query = new Query($bool); $query->setSize(20); $query->setSort(['createdAt' => ['order' => 'desc']]); /** @var Pagerfanta $pager */ $pager = $finder->findPaginated($query); $pager->setMaxPerPage(20); $pager->setCurrentPage(max(1, (int) $request->query->get('page', 1))); $articles = iterator_to_array($pager->getCurrentPageResults()); // (Optional) also show latest threads under this topic scope $page = max(1, (int) $request->query->get('page', 1)); $perPage = 20; $threads = $this->fetchThreads($index, [$tags]); // OR scope: any tag in subcategory $threadsPage = array_slice($threads, ($page-1)*$perPage, $perPage); return $this->render('forum/topic.html.twig', [ 'categoryKey' => $cat, 'subcategoryKey' => $sub, 'topic' => [ 'name' => $topic['name'], 'tags' => $tags, ], 'tags' => $tagCounts, // ['tag' => count] 'threads' => $threadsPage, 'total' => count($threads), 'page' => $page, 'perPage' => $perPage, 'topics' => $this->getHydratedTopics($index), 'articles' => $articles ]); } #[Route('/forum/tag/{tag}', name: 'forum_tag')] public function tag( string $tag, #[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, #[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index, Request $request ): Response { $tag = strtolower(trim($tag)); $bool = new BoolQuery(); // Correct Term usage: $bool->addFilter(new Term(['topics' => $tag])); $query = new Query($bool); $query->setSize(20); $query->setSort(['createdAt' => ['order' => 'desc']]); /** @var Pagerfanta $pager */ $pager = $finder->findPaginated($query); $pager->setMaxPerPage(20); $pager->setCurrentPage(max(1, (int) $request->query->get('page', 1))); $articles = iterator_to_array($pager->getCurrentPageResults()); return $this->render('forum/tag.html.twig', [ 'tag' => $tag, 'articles' => $articles, 'pager' => $pager, // expose if you want numbered pagination links 'topics' => $this->getHydratedTopics($index), ]); } // ---------- Helpers ---------- /** * Flatten all tags from the taxonomy into a unique set. * @return array */ private function flattenAllTags(array $categories): array { $set = []; foreach ($categories as $cat) { foreach ($cat['subcategories'] as $sub) { foreach ($sub['tags'] as $tag) { $set[strtolower($tag)] = true; } } } return $set; } /** * Run one ES query that returns counts for each tag (OR scope per tag). * Uses a Filters aggregation keyed by tag to avoid N queries. * * @param \Elastica\Index $index * @param string[] $tags * @return array */ private function fetchTagCounts(\Elastica\Index $index, array $tags): array { $tags = array_values(array_unique(array_map('strtolower', array_map('trim', $tags)))); if (!$tags) return []; $q = new Query(new Query\MatchAll()); $filters = new FiltersAgg('tag_counts'); foreach ($tags as $tag) { $b = new BoolQuery(); $b->addFilter(new Term(['topics' => $tag])); // topics must be keyword + lowercase normalizer $filters->addFilter($b, $tag); } $q->addAggregation($filters); $q->setSize(0); $res = $index->search($q); $agg = $res->getAggregation('tag_counts')['buckets'] ?? []; $out = []; foreach ($tags as $tag) { $out[$tag] = isset($agg[$tag]['doc_count']) ? (int) $agg[$tag]['doc_count'] : 0; } return $out; } /** * Rehydrate taxonomy with counts per subcategory (sum of its tags). * @param array $counts */ private function hydrateCategoryCounts(array $taxonomy, array $counts): array { $out = []; foreach ($taxonomy as $catKey => $cat) { $subs = []; foreach ($cat['subcategories'] as $subKey => $sub) { $sum = 0; foreach ($sub['tags'] as $tag) { $sum += $counts[strtolower($tag)] ?? 0; } $subs[$subKey] = $sub + ['count' => $sum]; } $out[$catKey] = $cat; $out[$catKey]['subcategories'] = $subs; } return $out; } /** * (Optional) Fetch latest threads for a given OR-scope of tag groups. * You can replace this with your Finder if you want entity hydration. * * @param array> $tagGroups e.g. [ ['bitcoin','lightning'] ] * @return array> */ private function fetchThreads(\Elastica\Index $index, array $tagGroups, int $size = 200): array { $bool = new BoolQuery(); // For a simple OR across tags: use Terms query on 'topics' // If you pass multiple groups and want AND across groups, adapt here. $flatTags = []; foreach ($tagGroups as $g) { foreach ($g as $t) { $flatTags[] = strtolower($t); } } $flatTags = array_values(array_unique($flatTags)); if ($flatTags) { $bool->addFilter(new Terms('topics', $flatTags)); } $q = (new Query($bool)) ->setSize($size) ->addSort(['createdAt' => ['order' => 'desc']]); $rs = $index->search($q); // Map raw sources you need (adjust to your mapping) return array_map(static function (\Elastica\Result $hit) { $s = $hit->getSource(); return [ 'id' => $s['id'] ?? $hit->getId(), 'title' => $s['title'] ?? '(untitled)', 'excerpt' => $s['excerpt'] ?? null, 'topics' => $s['topics'] ?? [], 'created_at' => $s['createdAt'] ?? null, ]; }, $rs->getResults()); } private function getHydratedTopics(\Elastica\Index $index): array { $allTags = $this->flattenAllTags(ForumTopics::TOPICS); $counts = $this->fetchTagCounts($index, array_keys($allTags)); return $this->hydrateCategoryCounts(ForumTopics::TOPICS, $counts); } }