Design a URL shortener — PHP/Laravel implementation walkthrough
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
// 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().