Laravel Magazine
Building a Collision-Safe URL Shortener in Laravel

Building a Collision-Safe URL Shortener in Laravel

Eric Van Johnson ·

URL shorteners look simple until you have to guarantee two users never get the same short code and figure out how to track clicks without slowing down every redirect. Here's a complete, production-shaped implementation.

Step 1: The migration

Schema::create('short_urls', function (Blueprint $table) {
    $table->id();
    $table->string('code', 10)->unique();
    $table->text('destination_url');
    $table->unsignedInteger('clicks')->default(0);
    $table->foreignId('user_id')->nullable()->constrained();
    $table->timestamp('expires_at')->nullable();
    $table->timestamps();
});

Step 2: Generating collision-safe codes

Rather than generating a random string and hoping it's unique, encode the row's own auto-incrementing ID into base-62. This guarantees uniqueness by construction, no collision checking required:

namespace App\Support;

class Base62
{
    private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

    public static function encode(int $number): string
    {
        if ($number === 0) {
            return self::ALPHABET[0];
        }

        $base = strlen(self::ALPHABET);
        $encoded = '';

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

        return $encoded;
    }
}

Step 3: The ShortUrl model

namespace App\Models;

use App\Support\Base62;
use Illuminate\Database\Eloquent\Model;

class ShortUrl extends Model
{
    protected $fillable = ['destination_url', 'user_id', 'expires_at'];

    protected static function booted(): void
    {
        static::created(function (ShortUrl $shortUrl) {
            $shortUrl->update(['code' => Base62::encode($shortUrl->id)]);
        });
    }

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }
}

Because the code is derived from the auto-incrementing primary key after the row is created, there is no window where two requests could generate and race to claim the same code. The database's own uniqueness guarantee for id does the work.

Step 4: The controller

namespace App\Http\Controllers;

use App\Models\ShortUrl;
use Illuminate\Http\Request;

class ShortUrlController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'destination_url' => ['required', 'url', 'max:2048'],
        ]);

        $shortUrl = ShortUrl::create([
            'destination_url' => $validated['destination_url'],
            'user_id' => $request->user()?->id,
        ]);

        return response()->json([
            'short_url' => url("/s/{$shortUrl->code}"),
        ]);
    }

    public function redirect(string $code)
    {
        $shortUrl = ShortUrl::where('code', $code)->firstOrFail();

        abort_if($shortUrl->isExpired(), 410, 'This link has expired.');

        ShortUrl::whereKey($shortUrl->id)->increment('clicks');

        return redirect()->away($shortUrl->destination_url);
    }
}

Using increment() on the query builder rather than $shortUrl->increment('clicks') on the loaded model avoids an extra SELECT and issues a single atomic UPDATE ... SET clicks = clicks + 1, which matters once a popular link is getting hit concurrently.

Step 5: Routes

use App\Http\Controllers\ShortUrlController;

Route::post('/urls', [ShortUrlController::class, 'store']);
Route::get('/s/{code}', [ShortUrlController::class, 'redirect'])->name('short-url.redirect');

Step 6: Keeping redirects fast with caching

If a link goes viral, you don't want every click hitting the database. Cache the lookup with a short TTL:

public function redirect(string $code)
{
    $destination = cache()->remember("short_url:{$code}", now()->addMinutes(10), function () use ($code) {
        return ShortUrl::where('code', $code)->firstOrFail()->destination_url;
    });

    ShortUrl::where('code', $code)->increment('clicks');

    return redirect()->away($destination);
}

Wrapping up

The trick that makes this whole system simple is letting the database's own auto-incrementing ID do the uniqueness work instead of generating random codes and checking for collisions. Combine that with an atomic increment for click counts and a short-lived cache in front of the lookup, and you have a shortener that holds up under real traffic without any locking or retry logic.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.