Introduction
Categories are the backbone of content organization on any video platform. But they're also one of the most-queried pieces of data — every page needs them for navigation. Here's how I implemented a category system with smart caching for ViralVidVault.
The Category Data Model
<?php
class Category
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $slug,
public readonly int $videoCount = 0,
public readonly ?string $iconEmoji = null,
) {}
public static function fromRow(array $row): self
{
return new self(
id: (int)$row['id'],
name: $row['name'],
slug: $row['slug'],
videoCount: (int)($row['video_count'] ?? 0),
iconEmoji: $row['icon_emoji'] ?? null,
);
}
}
The Repository with Cache
<?php
class CategoryRepository
{
private const CACHE_KEY = 'global:categories';
private const CACHE_TTL = 86400; // 24 hours
private \PDO $db;
private DataCache $cache;
public function __construct(\PDO $db, DataCache $cache)
{
$this->db = $db;
$this->cache = $cache;
}
public function getAll(): array
{
// Try cache first
$cached = $this->cache->get(self::CACHE_KEY);
if ($cached !== null) {
return array_map(fn($row) => Category::fromRow($row), $cached);
}
// Query database
$stmt = $this->db->query('
SELECT c.id, c.name, c.slug, c.icon_emoji,
COUNT(v.id) as video_count
FROM categories c
LEFT JOIN videos v ON v.category_id = c.id AND v.is_active = 1
GROUP BY c.id
HAVING video_count > 0
ORDER BY video_count DESC
');
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
// Store in cache
$this->cache->set(self::CACHE_KEY, $rows, self::CACHE_TTL);
return array_map(fn($row) => Category::fromRow($row), $rows);
}
public function getBySlug(string $slug): ?Category
{
$all = $this->getAll(); // Uses cache
foreach ($all as $category) {
if ($category->slug === $slug) {
return $category;
}
}
return null;
}
public function invalidateCache(): void
{
$this->cache->delete(self::CACHE_KEY);
}
}
The Data Cache Layer
<?php
class DataCache
{
private string $cacheDir;
public function __construct(string $cacheDir)
{
$this->cacheDir = rtrim($cacheDir, '/');
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
public function get(string $key): mixed
{
$file = $this->getPath($key);
if (!file_exists($file)) return null;
$data = unserialize(file_get_contents($file));
if ($data === false) return null;
if ($data['expires_at'] < time()) {
unlink($file);
return null;
}
return $data['value'];
}
public function set(string $key, mixed $value, int $ttl): void
{
$file = $this->getPath($key);
$data = [
'value' => $value,
'expires_at' => time() + $ttl,
'created_at' => time(),
];
file_put_contents($file, serialize($data), LOCK_EX);
}
public function delete(string $key): void
{
$file = $this->getPath($key);
if (file_exists($file)) unlink($file);
}
private function getPath(string $key): string
{
return $this->cacheDir . '/' . md5($key) . '.cache';
}
}
The Navigation Bar
Categories appear in the navigation bar on every page. Because they're cached for 24 hours, this query only hits the database once per day:
<!-- nav.php -->
<?php $categories = $categoryRepo->getAll(); ?>
<nav class="vw-catbar">
<?php foreach ($categories as $cat): ?>
<a href="/category/<?= $cat->slug ?>" class="vw-chip">
<?= $cat->iconEmoji ?> <?= htmlspecialchars($cat->name) ?>
<span class="count">(<?= $cat->videoCount ?>)</span>
</a>
<?php endforeach; ?>
</nav>
Cache Invalidation Strategy
At ViralVidVault, category cache is invalidated in two scenarios:
- After cron fetches new content (video counts change)
- After admin adds/removes a category (rare)
// In cron/fetch_videos.php, after all fetching:
$categoryRepo->invalidateCache();
The 24-hour TTL is a safety net — even if invalidation fails, the cache self-refreshes daily.
Why 24 Hours?
Categories on viralvidvault.com rarely change. The category list (Music, Entertainment, Gaming, News, Sports, etc.) is essentially static. Video counts within categories shift, but users don't notice if "Music (1,234)" is a few hours stale. The 24-hour TTL with explicit invalidation on cron gives us the best of both worlds: near-zero database load for navigation, with freshness after content updates.
Part of the "Building ViralVidVault" series.
Top comments (0)