Programming Tutorials

Build a URL Shortener From Scratch: A Beginner-Friendly Walkthrough

A hands-on walkthrough of building a working URL shortener from the database schema up, including short-code generation, collision handling, and the redirect route that ties it together.

By Aissam Ait Ahmed Programming Tutorials 0 comments

The first time I tried to build a URL shortener, I assumed the hard part would be the redirect. It isn't. A redirect is one line of code. The actual engineering problem is quieter and easier to get wrong: how do you turn a long URL into a short, unique code, over and over, without ever handing out the same code twice? That question is what this walkthrough is really about.

We'll build a minimal but genuinely functional URL shortener: a table to store the mappings, a function that generates short codes, a collision-safe way to insert new rows, and a route that resolves a code back to its original URL. Everything here is real, runnable code, not pseudocode with the details waved away.

Start With the Data Model

Before writing any generation logic, get the schema right. A URL shortener is fundamentally a lookup table, and the columns you choose now will save you pain later. Here's a minimal migration for a Laravel app:

Schema::create('urls', function (Blueprint $table) {
    $table->id();
    $table->text('long_url');
    $table->string('short_code', 10)->unique();
    $table->unsignedBigInteger('clicks')->default(0);
    $table->timestamps();
});

Two details matter here that are easy to skip past. First, long_url is a text column, not string — URLs with long query strings (think tracking parameters on a marketing link) can blow past 255 characters, and you don't want an insert to fail because someone pasted a URL from an email campaign. Second, short_code has a unique constraint at the database level. Application-level uniqueness checks are necessary but not sufficient; the database constraint is your last line of defense against a race condition where two requests generate the same code within milliseconds of each other.

Generating Short Codes

There are two common approaches. One is to auto-increment an integer ID and convert it to base62 (using the character set A-Z, a-z, 0-9), so record #125 becomes something like 21. This produces short, sequential codes, but sequential codes leak information — anyone can guess that 22 exists right after 21, and they can estimate how many links you've shortened by watching the counter climb.

The other approach, and the one I'd actually recommend for anything public-facing, is to generate a random string of fixed length and check it against the database. It's slightly more work, but it doesn't leak your row count and it doesn't require a separate encoding step. Here's a straightforward implementation:

function generateShortCode(int $length = 7): string
{
    $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    $code = '';
    $max = strlen($characters) - 1;

    for ($i = 0; $i < $length; $i++) {
        $code .= $characters[random_int(0, $max)];
    }

    return $code;
}

Note the use of random_int() rather than rand() or mt_rand(). It's cryptographically secure, which matters more than people assume — a predictable pseudo-random generator means an attacker could potentially enumerate your short codes faster than brute force would suggest. This is the same underlying concern behind any tool that generates random tokens; our password generator solves a related problem using the same principle of pulling from a secure random source instead of a predictable one.

Why 7 Characters, and When Collisions Actually Happen

With 62 possible characters and a length of 7, you get 62^7, which is a little over 3.5 trillion possible codes. That sounds effectively infinite, and for a small site it is. But "possible" and "collision-free" aren't the same thing — this is the birthday paradox territory. The odds of a specific collision are tiny, but the odds of any collision across many generated codes climb faster than intuition suggests.

I tested this myself by generating 500,000 codes at length 7 in a local script and checking for duplicates. I got zero collisions, which lines up with the math — at that volume, expected collisions are still a small fraction of one. But zero collisions in testing doesn't mean you can skip handling them; it means the handling code will rarely execute, which is exactly the kind of path that gets forgotten and then bites you at scale. Never skip the retry logic just because a test run came back clean.

Handling Collisions Without Overcomplicating It

The practical fix is a small retry loop: generate a code, check if it exists, and if it does, try again. Cap the retries so a bug elsewhere can't spin forever.

function createShortUrl(string $longUrl, PDO $db): string
{
    $attempts = 0;

    do {
        $code = generateShortCode();
        $stmt = $db->prepare('SELECT COUNT(*) FROM urls WHERE short_code = ?');
        $stmt->execute([$code]);
        $exists = (int) $stmt->fetchColumn() > 0;
        $attempts++;
    } while ($exists && $attempts < 5);

    if ($exists) {
        throw new RuntimeException('Could not generate a unique code after 5 attempts.');
    }

    $insert = $db->prepare('INSERT INTO urls (long_url, short_code, created_at) VALUES (?, ?, NOW())');
    $insert->execute([$longUrl, $code]);

    return $code;
}

Five attempts is generous given how rare collisions are at this code length, but the loop costs almost nothing and it turns a theoretical edge case into a handled one instead of a 2 a.m. production incident. The unique constraint on the column is still doing real work here too: if two concurrent requests somehow generate the same code between the check and the insert, the database will reject the second insert rather than silently overwriting the first link.

The Redirect Route

This is the part that feels anticlimactic after everything above, and that's fine — it should be simple. In a Laravel app, a catch-all route at the end of your route file looks up the code and redirects:

Route::get('/{code}', function (string $code) {
    $url = DB::table('urls')->where('short_code', $code)->first();

    if (! $url) {
        abort(404);
    }

    DB::table('urls')->where('id', $url->id)->increment('clicks');

    return redirect($url->long_url, 301);
});

A couple of choices here are worth explaining rather than glossing over. Using a 301 (permanent) redirect tells browsers and search engines this mapping won't change, which lets browsers cache it — good for performance, but it also means if you ever need to repoint a short code to a different destination, cached clients won't see the change immediately. If you want the flexibility to update destinations later, use a 302 instead and accept the small performance cost of no browser caching.

The click increment is also doing double duty: it's basic analytics, but it's also the first thing you'd build on top of if you wanted expiring links, click limits, or geographic redirect rules later. Keep it as a separate, obvious line rather than folding it into a more "clever" single query — you'll thank yourself when you're debugging why a counter looks wrong at 11 p.m.

Avoiding Characters People Actually Mistype

Something I only learned by watching real users struggle with a short link read aloud over the phone: the default character set above includes both 0 and O, and both l, 1, and I. In most fonts these look distinct enough on screen, but the moment someone reads a code out loud, or types it on a phone keyboard while glancing between two apps, those pairs become a real source of "the link doesn't work" support requests.

The fix is to trim the character set down to something unambiguous:

function generateShortCode(int $length = 7): string
{
    // no 0/O, no 1/l/I — removes the most commonly confused pairs
    $characters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
    $code = '';
    $max = strlen($characters) - 1;

    for ($i = 0; $i < $length; $i++) {
        $code .= $characters[random_int(0, $max)];
    }

    return $code;
}

This does shrink your total code space slightly — from 62 characters down to 58 — but at length 7 that's still roughly 1.7 trillion combinations, more than enough headroom that the trade-off is obviously worth it. It's a small change, and it's exactly the kind of thing that never shows up in a tutorial's "happy path" but does show up in a support inbox six months after launch.

Rate Limiting the Creation Endpoint

Without a limit, the endpoint that creates short URLs is an open invitation to be scripted — someone can write a five-line loop that hits it thousands of times a minute, either to spam the database or to scrape your service for free bulk link generation. In a Laravel app, the built-in throttle middleware handles this with almost no extra code:

Route::post('/shorten', [UrlController::class, 'store'])
    ->middleware('throttle:20,1');

That limits each client to 20 requests per minute, tracked by IP address (or by authenticated user, if the route requires login). It's not a sophisticated defense — someone determined enough can rotate IPs — but it stops the overwhelming majority of naive scripting attempts, and it costs one line to add. I'd treat this as a non-negotiable default, not an optimization to add later, since the cost of adding it up front is so much lower than the cost of cleaning up a database full of junk rows after the fact.

What a Production Version Adds

The code above is a genuinely working shortener, but there's a real gap between "working" and "production-ready." A production system needs rate limiting on the creation endpoint (otherwise someone can script-generate millions of rows), validation that rejects malformed or non-HTTP URLs before they ever reach the database, custom alias support so users can request yourdomain.com/summer-sale instead of a random string, and some form of link expiration or abuse reporting since shortened links are occasionally used to mask spam destinations.

Our own URL Shortener tool handles all of that under the hood — the validation, the abuse checks, the collision handling at higher volume — so if you build the version in this post and then look at what a live tool does differently, that's exactly where the extra engineering goes.

A Quick Checklist Before You Ship

  • Unique constraint on the short code column, not just an application-level check
  • Cryptographically secure random generation, not rand()
  • A bounded retry loop for the rare collision case
  • URL validation before insert (reject anything that isn't a well-formed http(s) URL)
  • A decision on 301 vs 302 based on whether destinations can ever change
  • Basic rate limiting on the creation endpoint

If you want to keep pushing this project further, a natural next step is writing tests for it before you add more features — the retry logic and the redirect behavior are both good first candidates. Our guide on writing your first automated test suite for a small PHP app walks through exactly that kind of code.

What you end up with is small enough to fully understand in one sitting, and that's the point. Most of the interesting engineering in a URL shortener isn't the redirect — it's making sure the thing generating your codes never quietly hands out a duplicate.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Programming Tutorials Free Resources Explore Tools