Project Walkthrough: Building a URL Shortener

Design and build a production-shaped URL shortener in PHP: generate collision-safe short codes, serve fast redirects backed by a Redis cache in front of an indexed database table, record click analytics asynchronously through a queue, and rate-limit the creation endpoint against abuse.

A URL shortener looks trivial on the surface — store a long URL, hand back a short code, redirect on lookup — but building one that survives real traffic touches nearly every layer of this course at once: short-ID generation and number-base math, database schema design and indexing from the php-database-indexes lesson, caching hot reads with Redis as covered in php-redis-and-memcached and php-caching-strategies, pushing slow work onto a queue the way php-background-queues-job-processing described, and defending the public write endpoint with the token-bucket and sliding-window techniques from php-rate-limiting-algorithms. This lesson walks through the whole system end to end, phase by phase, the way you'd actually build it: get a naive version working first, then harden it.

Project Overview and Requirements

Before writing a line of code it's worth pinning down what "URL shortener" actually needs to mean, because the phrase hides a surprising number of design decisions. At minimum: a user submits a long URL and receives back a short one, typically something like https://eh.gh/aZ9kQ1; visiting the short URL redirects the browser to the original long URL; and the system should not silently corrupt or lose that mapping under concurrent writes. Beyond that minimum, a shortener that's actually useful in production needs the redirect path — the hot path, hit potentially thousands of times per second — to be extremely fast, because every extra millisecond on a redirect is latency the end user feels directly, unlike a slow admin page nobody times.

The two paths through this system have very different performance profiles and very different failure tolerances, and that asymmetry drives almost every architectural decision that follows. The write path (creating a short URL) is comparatively rare, tolerant of a bit of latency, and needs strong consistency — you cannot hand out the same short code to two different long URLs. The read path (resolving a short code to its target) is comparatively frequent, latency-critical, and can tolerate slightly stale data — if a click's analytics are recorded a few hundred milliseconds late, nobody notices; if the redirect itself is slow, everybody notices.

We'll also scope in two features that separate a toy shortener from a real one: per-link click analytics (count, referrer, and timestamp, so a user can see how their link performed) and abuse protection on the creation endpoint (so a script can't flood the database with millions of junk rows). Both of these get bolted onto the core redirect/create flow without touching its fundamentals, which is itself a useful lesson in incremental system design — you don't need to plan every feature up front if the core data model is sound.

• Write path: submit a long URL, get back a short code — rare, latency-tolerant, must avoid duplicate/colliding codes. • Read path: visit a short code, get redirected — frequent, latency-critical, can tolerate slightly stale analytics. • Analytics (click count, referrer, timestamp) must not slow down the redirect itself. • The create endpoint is public-facing and needs rate limiting to prevent database-filling abuse.

Architecture and File Structure

The system splits cleanly into four collaborators: a UrlShortenerService that owns the business logic of creating and resolving links; a ShortCodeGenerator that turns a numeric ID into a compact string; a LinkCache wrapper around Redis that sits in front of the database for reads; and a queued RecordClickJob that persists analytics after the redirect has already been issued. Keeping these as separate, narrowly-scoped classes — rather than one big UrlController that does everything — means each piece can be unit-tested in isolation (the generator is pure math, easy to test exhaustively) and swapped independently (you could replace Redis with Memcached without touching the generator or the queue).

The database has a single links table: an auto-increment id, the long_url (text, since URLs can be arbitrarily long), the derived short_code (a short indexed varchar), a created_at timestamp, and a denormalized click_count column that we update asynchronously. A separate link_clicks table holds one row per click — link_id, referrer, user_agent, ip_hash, and clicked_at — for anyone who wants a detailed history rather than just a running total. Splitting the aggregate counter from the detail log is a deliberate choice: most reads only need the fast counter, and the detail table can grow arbitrarily large and even get archived or pruned without touching the hot links row.

The short_code column carries a UNIQUE index — this is what turns "is this code already taken" from an O(n) table scan into an O(log n) index lookup, and it also gives us a database-level guarantee against duplicate codes even if two requests race each other. This is the same indexing discipline covered in php-database-indexes: index the column you look up by, not the column you happen to display.

• UrlShortenerService — creates and resolves links (business logic). • ShortCodeGenerator — pure function: numeric ID → base62 string, or random string + collision check. • LinkCache — Redis-backed read-through cache in front of the links table. • RecordClickJob — queued job that writes analytics after the redirect response is already sent. • links(id, long_url, short_code UNIQUE INDEX, click_count, created_at) and link_clicks(link_id, referrer, user_agent, ip_hash, clicked_at).

Core Implementation: Short-Code Generation

There are two common strategies for turning a URL into a short code, and it's worth understanding the trade-off between them rather than just picking one. The first is base62 encoding of an auto-increment ID: insert the row first, get back the database-assigned integer ID, then encode that integer using the 62 characters 0-9a-zA-Z instead of the usual 10 digits. This is deterministic, collision-free by construction (two different IDs always encode to two different strings), and produces short codes almost immediately — a 6-character base62 string alone represents over 56 billion distinct values. Its one drawback is that codes are sequential and guessable in order, which leaks how many links exist and, if that matters for your product, needs a light obfuscation step (like XOR-ing the ID with a fixed key before encoding).

The second strategy is generating a random string directly — pick N random alphanumeric characters, check whether that code is already taken, and retry on the rare collision. This avoids leaking sequence information entirely, but it requires a database round-trip to check for collisions before you know the insert will succeed, and under high write concurrency you need to handle the case where two requests generate the same random code at nearly the same instant — which is exactly what the UNIQUE index on short_code is for: let the database reject the duplicate and retry with a fresh random code, rather than trying to prevent the race in application code.

We'll implement base62-of-an-ID as the primary approach because it's simpler to reason about and has zero collision risk, and show the random-with-retry approach as an alternative in the example code so you can see both patterns. Either way, the generator itself is a small, pure, and therefore highly testable piece of code — exactly the kind of unit you'd cover exhaustively with PHPUnit, as described back in the php-phpunit-fundamentals and php-testing-pyramid lessons.

• Base62 of an auto-increment ID: deterministic, zero collisions, but sequential/guessable unless obfuscated. • Random string + collision check: unguessable, but needs a DB round-trip and a retry-on-duplicate path. • The short_code UNIQUE index is your safety net against races in either approach — let the DB enforce uniqueness.

Core Implementation: The Cached Redirect Path

The redirect endpoint is the part of this system users actually feel, so it gets the most engineering attention. A naive implementation does SELECT long_url FROM links WHERE short_code = ? on every single request and issues the redirect — functionally correct, but it means every click, no matter how popular the link, does a database round trip. For a link that goes viral and gets hit 10,000 times a minute, that's 10,000 nearly-identical queries asking the database the same question over and over.

The fix is a read-through cache: before querying the database, check Redis for a key like link:{shortCode}. If it's present (a cache hit), skip the database entirely and redirect immediately — this is the fast path and, once a link has been resolved once, it stays fast for as long as the cache entry lives. If it's absent (a cache miss, which only happens for the first request after a link is created or after the cache entry expires), fall through to the database, and — critically — write the result back into Redis before returning, so the next request is a hit. This is the same read-through pattern from php-redis-and-memcached and php-caching-strategies, applied to the single hottest read in the whole application.

Cache invalidation here is refreshingly simple compared to most caching problems, because a long_url never changes once a short code is created — there's no update path to invalidate against, only a natural expiry (we use a generous TTL, say 24 hours, mostly as a safety valve against unbounded cache growth for links that are created and never clicked again, not because the data goes stale). This is a case where caching is close to free: high hit rate, no invalidation complexity, and a clear, isolated failure mode — if Redis is down, we simply fall back to hitting the database on every request, degraded but correct.

• On redirect: check Redis (link:{code}) first — hit means skip the database entirely. • On miss: query the indexed links table, then populate the cache before returning, so the next hit is fast. • No invalidation problem: long_url is immutable once created, so a TTL is just a memory safety valve, not a correctness mechanism. • Redis unavailable → fall back to direct DB reads; degraded latency, not a broken redirect.

Asynchronous Analytics via the Queue

It's tempting to record a click's referrer and timestamp with an INSERT right inside the redirect handler, right before issuing the Location header — but that puts a database write directly on the critical path of the fastest, most frequently hit endpoint in the system, which undoes a good chunk of the benefit we just got from caching the lookup. Instead, the redirect handler pushes a small message — short code, referrer header, timestamp, and a hashed IP for rough uniqueness — onto a queue and returns the redirect response immediately, without waiting for that message to be processed.

A separate worker process, running independently of the web server (using the same queue mechanics built out fully in php-background-queues-job-processing, or the from-scratch queue in the next lesson of this course), consumes those messages at its own pace and performs the actual INSERT INTO link_clicks plus an UPDATE links SET click_count = click_count + 1. If the worker falls behind during a traffic spike, clicks simply queue up and get processed a little later — the redirect never slows down, which is exactly the trade-off we want: users experience a fast redirect, and the link owner sees analytics that are accurate within a few seconds rather than instantaneously.

This separation — do the minimum synchronous work needed to serve the response, defer everything else — is one of the most broadly applicable patterns in backend engineering, and a URL shortener is a particularly clean example of it because the two halves (redirect vs. record) have no dependency on each other: the redirect doesn't need the click to be recorded to succeed, and the click recording doesn't need the redirect to still be in flight.

• Redirect handler enqueues {short_code, referrer, ip_hash, clicked_at} and returns immediately — no DB write on the hot path. • A separate worker consumes the queue and performs the INSERT into link_clicks plus the click_count increment. • Traffic spikes make the queue grow, not the redirect slow down — analytics become eventually consistent, which is fine for this use case.

Rate Limiting the Create Endpoint

The redirect endpoint is read-only and cheap once cached, so it's a low-value target for abuse. The create endpoint is a different story: it does a database write, and an attacker with a script can hammer it to fill the links table with garbage, exhaust your auto-increment ID space, or simply run up your database costs. This endpoint needs the rate limiting covered in php-rate-limiting-algorithms — specifically a per-IP (and, if users are authenticated, per-account) limit using a sliding-window or token-bucket counter stored in Redis, since Redis is already in the stack for the link cache and is a natural fit for the atomic increment-and-expire pattern rate limiting needs.

A reasonable policy is something like 20 creates per minute per IP for anonymous traffic, with a much higher limit for authenticated accounts in good standing — enforced by checking and incrementing a Redis counter keyed by ratelimit:create:{ip} before the insert runs, and returning an HTTP 429 Too Many Requests with a Retry-After header when the limit is exceeded. Because the limiter shares the same Redis instance as the link cache, it's worth keeping the two concerns in separate key namespaces (link:* vs ratelimit:*) so they don't collide and so each can be given its own eviction policy if the instance ever comes under memory pressure.

• Create endpoint does a DB write → needs rate limiting; redirect endpoint is read-mostly and cached → doesn't. • Sliding-window or token-bucket counter in Redis, keyed per IP (and per account for authenticated users). • Exceeding the limit returns 429 with a Retry-After header, not a silent failure or a 500. • Namespace rate-limit keys (ratelimit:*) separately from cache keys (link:*) in the same Redis instance.

Code Examples

The base62 approach never needs a database read to check for collisions because uniqueness falls straight out of the math — every distinct positive integer id produces a distinct string. The random approach trades that guarantee for unguessable codes, at the cost of a collision-check round trip; letting the caller inject an $exists callback keeps this class free of any database dependency, so it stays a pure, exhaustively unit-testable component.

resolve() checks Redis before ever touching PDO, so a hot link never causes a database read after its first request. recordClickAsync() only pushes a lightweight message onto the queue — the actual INSERT happens later in a worker, off the request path entirely. create() checks the rate limiter first (fail fast, before doing any work), and derives the short code from the real auto-increment id after insert, so fromId() never has to guess or reserve an id in advance.

The header() call happens before the analytics call, and recordClickAsync() itself does nothing more than a fast Redis/queue push — so the time between receiving the request and the browser starting its next navigation is dominated entirely by the cached lookup, not by any bookkeeping work.

Recording clicks with a synchronous INSERT INTO link_clicks inside the redirect handler, before issuing the Location header.

Issue the redirect first, then push a small analytics message onto a queue for a background worker to persist.

A synchronous write on the hottest endpoint in the app means every single redirect pays for a database round trip, which defeats much of the point of caching the URL lookup in the first place. Deferring the write keeps the user-facing latency low and tolerant of database or table lock hiccups that would otherwise show up as slow redirects.

Generating a random short code and inserting it without a UNIQUE index on short_code, relying only on an application-level SELECT ... WHERE short_code = ? check before the insert.

Put a UNIQUE index on short_code and let the database reject a duplicate insert, then retry with a fresh random code.

A check-then-insert without a database constraint has a race window: two requests can both check, both see no existing row, and both insert the same code before either commits. Only a database-level unique constraint closes that race completely; the application-level check is just an optimization to avoid triggering that constraint violation in the common case.

Caching the short-code → URL mapping with no TTL at all, on the theory that the mapping never changes.

Cache with a generous TTL (e.g. 24 hours) even though invalidation is never needed for correctness.

Skipping the TTL is not a correctness bug — the URL genuinely never changes — but it lets Redis memory grow forever as new links accumulate, since nothing ever evicts an old entry for a link that stopped being clicked. A TTL turns unbounded growth into a bounded working set of only the links that are actually still being used.

Why does the redirect handler push analytics onto a queue instead of writing them directly to the database before redirecting? A. Because PHP cannot perform two database queries in a single request B. Because a synchronous write on the hottest, most latency-sensitive endpoint would slow down every redirect C. Because queues are required by PHP's redirect functions D. Because Redis cannot store click data, only short codes

B. Because a synchronous write on the hottest, most latency-sensitive endpoint would slow down every redirect — The redirect path is hit far more often than the create path and users feel its latency directly, so any work that isn't strictly needed to compute the redirect (like recording analytics) is deferred to a background worker via a queue.

What ultimately prevents two concurrent requests from being assigned the same randomly generated short code? A. PHP's random_int() function never repeats within a process B. A UNIQUE index/constraint on the short_code column, which rejects the second duplicate insert C. The application checks for existing codes before generating a new one, which is sufficient on its own D. Redis automatically deduplicates keys

B. A UNIQUE index/constraint on the short_code column, which rejects the second duplicate insert — An application-level existence check has a race window between the check and the insert. Only a database-enforced unique constraint eliminates the race entirely; the application check is just an optimization that avoids hitting that constraint in the common, non-racing case.

Why is caching the long_url particularly simple compared to typical caching problems? A. Because Redis has no eviction policy B. Because long_url never changes once a link is created, so there is no invalidation logic needed, only a TTL as a memory safety valve C. Because the links table has no primary key D. Because PHP automatically caches all database reads

B. Because long_url never changes once a link is created, so there is no invalidation logic needed, only a TTL as a memory safety valve — Most caching systems need to invalidate an entry when the underlying data changes. Here, the mapped URL is immutable after creation, so the cache never needs to be invalidated for correctness — the TTL exists purely to bound memory usage for links that stop being clicked.

Why does the create endpoint need rate limiting while the redirect endpoint does not need the same protection? A. Redirects are always served from a CDN so PHP never sees them B. The create endpoint performs a database write and can be abused to fill the table, while the cached redirect endpoint is a cheap, mostly read-only lookup C. Rate limiting is required by HTTP for all 3xx responses D. The redirect endpoint cannot be rate-limited because it uses GET requests

B. The create endpoint performs a database write and can be abused to fill the table, while the cached redirect endpoint is a cheap, mostly read-only lookup — Writes are more expensive and more damaging to abuse than cached reads — an attacker hammering the create endpoint can flood the database with junk rows, so it needs a Redis-backed limiter, whereas the redirect endpoint, once cached, is cheap enough that abuse has little effect.

<?php
declare(strict_types=1);

final class ShortCodeGenerator
{
    private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

    /**
     * Deterministic: encode an auto-increment DB id as base62.
     * Never collides, because distinct ids always map to distinct strings.
     */
    public function fromId(int $id): string
    {
        if ($id <= 0) {
            throw new InvalidArgumentException('id must be a positive integer');
        }

        $base = strlen(self::ALPHABET); // 62
        $code = '';

        while ($id > 0) {
            $remainder = $id % $base;
            $code = self::ALPHABET[$remainder] . $code;
            $id = intdiv($id, $base);
        }

        // Pad to a minimum length so early ids don't look suspiciously short.
        return str_pad($code, 6, '0', STR_PAD_LEFT);
    }

    /**
     * Alternative: random code, checked against the database for collisions.
     * $exists is a callback (e.g. wrapping a PDO query) returning bool.
     */
    public function randomWithRetry(callable $exists, int $length = 7, int $maxAttempts = 5): string
    {
        for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
  
…
<?php
declare(strict_types=1);

final class UrlShortenerService
{
    public function __construct(
        private readonly PDO $db,
        private readonly Redis $redis,
        private readonly ShortCodeGenerator $generator,
        private readonly JobQueue $queue,
    ) {}

    /** Read path: resolve a short code to its long URL, cache-first. */
    public function resolve(string $shortCode): ?string
    {
        $cacheKey = "link:{$shortCode}";

        $cached = $this->redis->get($cacheKey);
        if ($cached !== false) {
            return $cached; // Cache hit — no database round trip at all.
        }

        $stmt = $this->db->prepare(
            'SELECT long_url FROM links WHERE short_code = :code LIMIT 1'
        );
        $stmt->execute(['code' => $shortCode]);
        $longUrl = $stmt->fetchColumn();

        if ($longUrl === false) {
            return null; // Unknown code — caller returns a 404.
        }

        // Populate the cache so the next request for this code is a hit.
        $this->redis->setex($cacheKey, 86400, $longUrl);

        return $longUrl;
    }

    /** Called from the redirect controller AFTER the redirect has been issued. */
    publi
…
<?php
declare(strict_types=1);

function handleRedirect(UrlShortenerService $service, string $shortCode): void
{
    $longUrl = $service->resolve($shortCode);

    if ($longUrl === null) {
        http_response_code(404);
        echo 'Short link not found';
        return;
    }

    // Issue the redirect FIRST — the user should never wait on analytics.
    header("Location: {$longUrl}", true, 301);

    $referrer = $_SERVER['HTTP_REFERER'] ?? '';
    $clientIp = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    $service->recordClickAsync($shortCode, $referrer, $clientIp);
}