0

Design a URL shortener — PHP/Laravel implementation walkthrough

Advanced5 min read·eng-11-001
interviewsql

Concept

Design a URL shortener — a classic system design problem. bit.ly / tinyurl.com in Laravel.

Requirements:

  • Given a long URL, generate a short code (e.g., abc123).
  • Given a short code, redirect to the original URL.
  • Short codes should be unique.
  • (Optional) Custom aliases, expiry, analytics.

Core design decisions:

1. Short code generation:

  • Hash-based: MD5/SHA1 the long URL, take first 6-8 characters. Problem: collisions (two different URLs hash to the same prefix).
  • Counter + base62: Use an auto-increment ID, encode in base62 (0-9A-Za-z). ID 1 → 1, ID 100 → 1C, ID 100000 → q0U. Pros: no collisions. Cons: predictable/sequential codes. Fix: shuffle the base62 alphabet.
  • Random string: Generate a random 6-8 character string, check uniqueness. Simple but needs DB lookup to verify.

2. Storage: One table: short_urls(id, code UNIQUE, original_url, user_id, expires_at, created_at). Index on code (lookups) and user_id (user's links).

3. Redirect flow: GET /{code} → look up in Redis cache first → if miss, query DB → return 301 (permanent, cached by browser) or 302 (temporary, not cached — better for analytics).

4. Caching: Cache code → url in Redis. TTL = expiry date or 24h. Most short URLs are accessed heavily in the first hour, then rarely.

5. Analytics: Log clicks asynchronously (queued job). Track: IP, user agent, referrer, timestamp. Don't let analytics slow down the redirect.

Scale considerations: At 10M redirects/day, the bottleneck is read throughput. Redis handles 100K+ reads/sec. Use Redis cluster for scale. Database is only hit on cache miss.

Code Example

php
<?php
// Base62 encoding
class Base62
{
    private const CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

    public static function encode(int $id): string
    {
        if ($id === 0) return self::CHARS[0];
        $code = '';
        while ($id > 0) {
            $code = self::CHARS[$id % 62] . $code;
            $id   = intdiv($id, 62);
        }
        return $code;
    }

    public static function decode(string $code): int
    {
        $id = 0;
        foreach (str_split($code) as $char) {
            $id = $id * 62 + strpos(self::CHARS, $char);
        }
        return $id;
    }
}

// Service
class UrlShortenerService
{
    public function __construct(
        private readonly \Illuminate\Contracts\Cache\Repository $cache,
    ) {}

    public function shorten(string $url, ?string $alias = null, ?\DateTime $expiresAt = null): ShortUrl
    {
        // Check for existing short URL
        $existing = ShortUrl::where('original_url', $url)->whereNull('alias')->first();
        if ($existing && !$alias) return $existing;

        $shortUrl = ShortUrl::create([
            'original_url' => $url,
            'alias'        => $alias,
            'expires_at'   => $expiresAt,
            'user_id'      => auth()->id(),
        ]);

        // Generate code from ID if no alias
        if (!$alias) {
            $shortUrl->code = Base62::encode($shortUrl->id);
            $shortUrl->save();
        } else {
            $shortUrl->code = $alias;
            $shortUrl->save();
        }

        return $shortUrl;
    }

    public function resolve(string $code): ?string
    {
        return $this->cache->remember("short:{$code}", 86400, function () use ($code) {
            $shortUrl = ShortUrl::where('code', $code)->first();
            if (!$shortUrl) return null;
            if ($shortUrl->expires_at && $shortUrl->expires_at < now()) {
                $this->cache->forget("short:{$code}");
                return null;
            }
            return $shortUrl->original_url;
        });
    }
}

// Controller
class ShortUrlController extends Controller
{
    public function redirect(string $code, UrlShortenerService $service): \Illuminate\Http\RedirectResponse
    {
        $url = $service->resolve($code);
        if (!$url) abort(404);

        // Log click asynchronously — don't slow down the redirect
        LogUrlClick::dispatch($code, request()->ip(), request()->userAgent())->onQueue('analytics');

        return redirect($url, 302); // 302 for analytics; 301 would cache in browsers
    }
}

Interview Q&A

Q: Why base62 over UUID for short codes? A: Base62 of an auto-increment ID is short (6 chars for IDs up to ~56 billion), guaranteed unique, and sequential. UUIDs are 36 chars — too long. Random strings need a uniqueness check.

Q: Why cache with TTL = 24h if URLs don't expire? A: Evict cache entries that haven't been accessed recently. If a URL expires, the service invalidates the cache entry immediately via cache()->forget().