DEV Community

ahmet gedik
ahmet gedik

Posted on

How to Implement Video Categories with Caching Strategy

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,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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';
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Cache Invalidation Strategy

At ViralVidVault, category cache is invalidated in two scenarios:

  1. After cron fetches new content (video counts change)
  2. After admin adds/removes a category (rare)
// In cron/fetch_videos.php, after all fetching:
$categoryRepo->invalidateCache();
Enter fullscreen mode Exit fullscreen mode

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)