id . '.' . $limit, self::CACHE_TTL, fn () => $this->compute($card, $limit), ); } return $this->compute($card, $limit); } public function invalidateForCard(NovaCard $card): void { foreach ([4, 6, 8, 12] as $limit) { Cache::forget('nova_cards.related.' . $card->id . '.' . $limit); } } private function compute(NovaCard $card, int $limit): Collection { $card->loadMissing(['tags', 'category', 'template']); $tagIds = $card->tags->pluck('id')->all(); $templateId = $card->template_id; $categoryId = $card->category_id; $format = $card->format; $styleFamily = $card->style_family; $paletteFamily = $card->palette_family; $creatorId = $card->user_id; $query = NovaCard::query() ->publiclyVisible() ->where('nova_cards.id', '!=', $card->id) ->select(['nova_cards.*']) ->selectRaw('0 AS relevance_score'); // We build a union-ranked set via scored sub-queries, then re-aggregate // in PHP (simpler than scoring in MySQL without a full-text index). $candidates = NovaCard::query() ->publiclyVisible() ->where('nova_cards.id', '!=', $card->id) ->where(function ($q) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): void { $q->whereHas('tags', fn ($tq) => $tq->whereIn('nova_card_tags.id', $tagIds)) ->orWhere('template_id', $templateId) ->orWhere('category_id', $categoryId) ->orWhere('format', $format) ->orWhere('style_family', $styleFamily) ->orWhere('palette_family', $paletteFamily) ->orWhere('user_id', $creatorId); }) ->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags']) ->limit(80) ->get(); // Score in PHP — lightweight for this candidate set size. $scored = $candidates->map(function (NovaCard $c) use ($tagIds, $templateId, $categoryId, $format, $styleFamily, $paletteFamily, $creatorId): array { $score = 0; // Tag overlap: up to 10 points $overlap = count(array_intersect($c->tags->pluck('id')->all(), $tagIds)); $score += min($overlap * 2, 10); // Same template: 5 pts if ($templateId && $c->template_id === $templateId) { $score += 5; } // Same category: 3 pts if ($categoryId && $c->category_id === $categoryId) { $score += 3; } // Same format: 2 pts if ($c->format === $format) { $score += 2; } // Same style family: 4 pts if ($styleFamily && $c->style_family === $styleFamily) { $score += 4; } // Same palette: 3 pts if ($paletteFamily && $c->palette_family === $paletteFamily) { $score += 3; } // Same creator (more cards by creator): 1 pt if ($c->user_id === $creatorId) { $score += 1; } // Engagement quality boost (saves + remixes weighted) $engagementBoost = min(($c->saves_count + $c->remixes_count * 2) * 0.1, 3.0); $score += $engagementBoost; return ['card' => $c, 'score' => $score]; }); return $scored ->sortByDesc('score') ->take($limit) ->pluck('card') ->values(); } }