How To Create Hashed URL? Laravel Short URL Generator (2026)
Last updated: August 10, 2026. Written for Laravel 12 and Laravel 13 on PHP 8.3 or later. The original version of this post was written for Laravel 10, which is now past end of life.
A hashed URL is a short code that stands in for a long web address. Your application saves the long address in a database row, attaches a short code to that row, and then sends every visitor who opens yoursite.com/a7Kd92p to the address you saved. Building one in Laravel takes a model, a migration, two routes and a controller. Keeping it working after a few thousand links is where the interesting part starts.
Short links have a bad name in security circles for a reason. About one in five phishing emails now sends the reader through a redirect before they land anywhere real, and 10.2 percent of those redirects go through a link shortener, according to Abnormal AI’s 2026 Attack Landscape Report (retrieved 2026-09-01). If you are shipping a shortener that anyone can post to, you are shipping a redirect service, and that comes with responsibilities.
This guide walks through the full build: the schema, the code generation, the redirect, the click counter and the reporting endpoint. It also covers the two mistakes I see most often in short URL tutorials, both of which were in the first version of this post.
Key Takeaways
- Laravel 10 stopped receiving security fixes on February 4th, 2025. Build this on Laravel 12 or 13, which are supported until February 2027 and March 2028 (Laravel support policy, 2026).
- Chopping the first six characters off a SHA-256 hash gives you roughly a 53 percent chance of at least one collision by your 5,000th link. Use a random code with a unique database index instead.
- Store a SHA-256 fingerprint of the original URL in its own indexed column so repeat submissions return the same short link without a slow text comparison.
- Count clicks with
increment(), which runsclicks = clicks + 1in SQL. Reading the value into PHP and writing it back loses counts under concurrent traffic.- Put a pattern constraint on the catch-all redirect route and register it last, or it will swallow every other URL on your site.
What Is a Hashed URL?
A hashed URL is a short public code that your application maps back to a long private address. The word “hashed” here is doing a bit of marketing work, because the short code you hand out does not need to be a hash at all. What matters is that the code is short, hard to guess in bulk, and unique across your whole table.
Here is the flow in plain terms. A user posts a long URL to your API. You save it. You generate a seven character code and save that in the same row. You return https://yoursite.com/a7Kd92p to the user. Later, someone opens that address, your route matches the code, you look up the row, you add one to the click counter, and you send a redirect response to the browser with the original address in the Location header.
Three things live in that flow that a beginner tutorial usually skips. The code has to survive collisions. The redirect has to be safe for the person clicking it. And the click counter has to stay accurate when two people click the same link in the same millisecond. All three are covered below.
Which Laravel Version Should You Build This On?
Use Laravel 12 or Laravel 13. Everything in this guide runs on both. Laravel 10, which the first version of this post targeted, stopped receiving bug fixes on August 6th, 2024 and stopped receiving security fixes on February 4th, 2025, according to the Laravel support policy (retrieved 2026-09-01). Laravel 11 followed it out of security support on March 12th, 2026.
| Version | PHP | Released | Security fixes until |
|---|---|---|---|
| 10 | 8.1 to 8.3 | Feb 14, 2023 | Feb 4, 2025 (ended) |
| 11 | 8.2 to 8.4 | Mar 12, 2024 | Mar 12, 2026 (ended) |
| 12 | 8.2 to 8.5 | Feb 24, 2025 | Feb 24, 2027 |
| 13 | 8.3 to 8.5 | Mar 17, 2026 | Mar 17, 2028 |
If you already have a Laravel 10 shortener running in production, the code in this guide will drop into it with almost no changes. The upgrade to 12 is worth scheduling separately. Running a public redirect service on a framework that no longer gets security patches is a bad combination.
How Do You Set Up the Project?
Start a fresh application, or skip this step if you are adding the feature to an existing one.
composer create-project laravel/laravel short-url
cd short-url
php artisan make:model Url -m
php artisan make:controller UrlController
Now the migration. This schema does more work than the usual three column version, and every extra column earns its place.
// database/migrations/xxxx_xx_xx_create_urls_table.php
public function up(): void
{
Schema::create('urls', function (Blueprint $table) {
$table->id();
$table->text('original_url');
$table->char('url_fingerprint', 64)->unique();
$table->string('code', 12)->unique();
$table->unsignedBigInteger('clicks')->default(0);
$table->timestamp('last_clicked_at')->nullable();
$table->timestamps();
});
}
original_url is a text column because real URLs get long. Campaign links with UTM parameters cross 500 characters regularly, and a varchar(255) will silently truncate them or throw, depending on your database mode.
url_fingerprint holds a SHA-256 of the original URL. This is where hashing actually helps you. MySQL cannot put a unique index on a text column without a prefix length, so if you want repeat submissions of the same long URL to return the same short code, you need a fixed length column to index. A 64 character hash is that column, and the unique constraint on it does the deduplication work for you at the database level.
code is the public short code, and the unique index on it is the thing standing between you and duplicate short links. clicks and last_clicked_at handle the tracking side.
// app/Models/Url.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Url extends Model
{
protected $fillable = [
'original_url',
'url_fingerprint',
'code',
];
protected $casts = [
'last_clicked_at' => 'datetime',
'clicks' => 'integer',
];
}
Run php artisan migrate and the storage side is done.

Why Does a Truncated SHA-256 Break?
Almost every short URL tutorial on the internet, including the first version of this one, generates the code like this:
// Do not use this
$hash = substr(hash('sha256', $originalUrl), 0, 6);
Six hexadecimal characters give you 166 possible codes, which is 16,777,216. That number looks comfortable. It is not the number that matters. What matters is how many links you can store before two different URLs produce the same first six characters, and that arrives far sooner than most people expect.
This is the birthday problem. With 16.7 million slots, the chance of at least one collision passes 50 percent at roughly 5,000 stored links. A small internal tool hits 5,000 links in a year. A public shortener hits it in a week.
There is a second problem with hashing the URL to make the public code, and it is quieter. The same input always produces the same hash. Anyone who wants to know whether a particular URL has been shortened on your service can hash it themselves and check. For a marketing link that is harmless. For an unlisted document link it is a leak.
Keep the SHA-256 for the fingerprint column, where determinism is exactly what you want. Generate the public code some other way.
How Do You Generate a Short Code That Does Not Collide?
Generate a random code, let the unique database index reject duplicates, and retry when it does. Str::random() is built on PHP’s random_bytes(), so the output is cryptographically secure rather than merely shuffled, per the Laravel strings documentation (retrieved 2026-09-01).
Seven characters from Laravel’s alphanumeric alphabet gives 627 combinations, which is about 3.5 trillion. At a million stored links the collision probability sits near 0.000014 percent, and the retry loop below handles the rare hit anyway.
// app/Http/Controllers/UrlController.php
namespace App\Http\Controllers;
use App\Models\Url;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class UrlController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([
'url' => ['required', 'url:http,https', 'max:2048'],
]);
$fingerprint = hash('sha256', $data['url']);
$existing = Url::where('url_fingerprint', $fingerprint)->first();
if ($existing) {
return response()->json([
'short_url' => url($existing->code),
'code' => $existing->code,
], 200);
}
$url = $this->createWithFreshCode($data['url'], $fingerprint);
return response()->json([
'short_url' => url($url->code),
'code' => $url->code,
], 201);
}
protected function createWithFreshCode(string $originalUrl, string $fingerprint): Url
{
foreach (range(1, 5) as $attempt) {
try {
return Url::create([
'original_url' => $originalUrl,
'url_fingerprint' => $fingerprint,
'code' => Str::random(7),
]);
} catch (UniqueConstraintViolationException $e) {
continue;
}
}
abort(503, 'Could not allocate a short code. Please retry.');
}
}
UniqueConstraintViolationException is a dedicated exception class rather than a generic QueryException, so the catch block only swallows the case you meant to handle. A broken column name or a dropped connection still surfaces as a real error.
The validation rule url:http,https rejects javascript:, data: and file: schemes. Without that restriction, someone can store javascript:alert(document.cookie) as an “original URL” and use your domain to host it. The max:2048 matches the practical URL length limit that browsers and proxies respect.
How Do You Handle the Redirect Safely?
Two parts to this. The route has to be constrained, and the redirect has to be the right kind.
// routes/web.php
use App\Http\Controllers\UrlController;
Route::post('/api/links', [UrlController::class, 'store']);
Route::get('/api/links/{code}/stats', [UrlController::class, 'stats']);
// Keep this LAST in the file
Route::get('/{code}', [UrlController::class, 'redirect'])
->where('code', '[A-Za-z0-9]{7}');
An unconstrained Route::get('/{code}') matches every single segment path on your domain. Your /about page, your /login page and your health check endpoint all disappear into the redirect controller and start returning 404 from the URL lookup. The where() constraint limits the match to exactly seven alphanumeric characters, and registering the route last means Laravel checks every named route before it falls through.
public function redirect(string $code)
{
$url = Url::where('code', $code)->firstOrFail();
$url->increment('clicks', 1, [
'last_clicked_at' => now(),
]);
return redirect()->away($url->original_url, 302);
}
redirect()->away() is the method Laravel provides for sending users to a domain you do not control. The plain redirect()->to() helper runs the address through the URL generator, which can mangle an external address or reject it outright.
The status code deserves a moment. A 301 tells the browser the move is permanent, and the browser then caches it and stops asking your server. Your click counter goes quiet after the first visit from each device. Use 302 so every click reaches your application.
You are now running an open redirect, which is on the OWASP list of things attackers look for. A phisher can shorten a malicious page through your service and send the link out wearing your domain name. Read the OWASP Unvalidated Redirects and Forwards cheat sheet (retrieved 2026-09-01) before you open the endpoint to the public. At minimum, require authentication on store, rate limit it, and keep a record of which account created which link.
How Do You Track Clicks Without Losing Counts?
The version in the original post looked like this:
// Do not use this
$url->update(['clicks' => $url->clicks + 1]);
PHP reads the current value, adds one in memory, and writes the result back. Two requests arriving at the same time both read 40, both calculate 41, and both write 41. One click vanishes. On a link that is doing any real traffic, your reported numbers drift low and there is no error anywhere to tell you why.
increment() sends UPDATE urls SET clicks = clicks + 1 to the database, and the database handles the arithmetic under its own row lock. Both requests land, both count. The second argument to increment() is the step, and the third is an array of other columns to update in the same query, which is how last_clicked_at gets set without a second write.
For higher traffic, move the counter out of the request path entirely. Push a lightweight job onto a queue and let a worker batch the updates, or buffer counts in Redis and flush them to the database every minute. The redirect response then goes out without waiting on a write at all. That optimisation only pays off past a few hundred clicks a second, so leave it until you need it.
How Do You Report Clicks Back?
public function stats(string $code)
{
$url = Url::where('code', $code)->firstOrFail();
return response()->json([
'code' => $url->code,
'original_url' => $url->original_url,
'clicks' => $url->clicks,
'last_clicked_at' => $url->last_clicked_at?->toIso8601String(),
'created_at' => $url->created_at->toIso8601String(),
]);
}
Put this endpoint behind authentication. A public stats route lets anyone enumerate codes and read the original URL for each one, which cancels out the privacy benefit of a random code.
What Are the API Endpoints?
| Method | Path | What it does |
|---|---|---|
| POST | /api/links | Takes url, returns the short link. 201 for a new link, 200 for one that already existed. |
| GET | /{code} | Adds one to the click count and issues a 302 to the original address. |
| GET | /api/links/{code}/stats | Returns click count, last click time and the original URL. |
Test the create endpoint from the terminal:
curl -X POST http://localhost:8000/api/links \
-H "Accept: application/json" \
-d "url=https://www.prashantwebdeveloper.in/services/"
# {"short_url":"http://localhost:8000/a7Kd92p","code":"a7Kd92p"}
What Should You Add Before Going to Production?
- Rate limit the create endpoint. Laravel’s
throttlemiddleware handles this in one line. Without it, a script can fill your table overnight. - Require authentication to create links. Sanctum tokens are enough for an API. Store the creating user’s ID on the row so you can remove everything from one account when abuse shows up.
- Cache the code lookup. The redirect route runs a database query on every single click. A short Redis cache keyed on the code takes that load off the database, and you clear the key when the link is edited or deleted.
- Add an expiry column. Links that live forever become links you cannot audit. A nullable
expires_atand a check in the redirect method cost you almost nothing to add now. - Give the shortener its own domain. Keeping short links on a separate hostname stops the catch-all route from ever competing with your main application’s routes, and it keeps your primary domain out of any spam blocklist your shortener attracts.
- Write a feature test for the redirect. One test that posts a URL, follows the returned short link, and asserts both the destination and the click count. That single test catches nearly every regression this feature is prone to.
Frequently Asked Questions
Should I use a hash or a random string for the short code?
Use a random string for the public code and keep the hash for internal deduplication. A truncated hash collides sooner than most people expect, and because hashing is deterministic, anyone can check whether a specific URL exists on your service by hashing it themselves. A random 7-character code from Str::random() gives about 3.5 trillion combinations and leaks nothing about the destination.
How many URLs can a 6-character hashed code handle?
Fewer than the 16.7 million total combinations suggest. By the birthday approximation, the chance of at least one collision reaches about 3 percent at 1,000 stored links, 53 percent at 5,000, and 95 percent at 10,000. Any short URL system that is expected to grow should use a longer code with a unique database index behind it.
Does this Laravel short URL generator still work on Laravel 10?
The code runs on Laravel 10, but Laravel 10 stopped receiving security fixes on February 4th, 2025, and Laravel 11 stopped on March 12th, 2026 (Laravel support policy, 2026). Build new work on Laravel 12 or 13. UniqueConstraintViolationException is available from Laravel 10.36 onward, so older 10.x releases need a QueryException catch instead.
Why should the redirect be a 302 instead of a 301?
A 301 tells the browser the move is permanent, so the browser caches it and stops requesting your server on later clicks. Your click counter then stops rising even though people are still using the link. A 302 keeps every click flowing through your application, which is what click tracking depends on.
How do I stop my URL shortener from being used for phishing?
Require authentication to create links, rate limit the create endpoint, record which account created each link, and give yourself a fast way to disable a code. Around 10.2 percent of phishing attacks that use redirects go through link shortener services (Abnormal AI, 2026 Attack Landscape Report), so an open, anonymous shortener will attract abuse.
Wrapping Up
The short URL generator itself is a small piece of work. A model, a migration, three routes and a controller, and you have something running in an afternoon. What separates a demo from something you can leave in production is the handful of decisions around it: a random code with a unique index behind it, a fingerprint column that does the deduplication, a constrained catch-all route, an atomic click counter, and a redirect endpoint you have actually thought about defending.
I have built this pattern into client systems where the short link was the least interesting part of the job, and the click data behind it drove an entire reporting dashboard. The same reasoning about database constraints, race conditions and route ordering shows up in every backend I build, whether it is a link shortener or an AI agent’s API layer.
Building something like this and want a second pair of eyes on the schema or the security side? Message me on WhatsApp and tell me what you are building.
Sources
- Laravel, “Release Notes: Versioning Scheme and Support Policy,” retrieved 2026-09-01
- Laravel, “Strings: Str::random,” retrieved 2026-09-01
- Laravel, “Routing: Regular Expression Constraints,” retrieved 2026-09-01
- Laravel, “Eloquent: increment and decrement,” retrieved 2026-09-01
- Abnormal AI, “2026 Attack Landscape Report: Phishing Tactics Calibrate to Your Environment,” retrieved 2026-09-01
- OWASP, “Unvalidated Redirects and Forwards Cheat Sheet,” retrieved 2026-09-01
- PHP Manual, “random_bytes,” retrieved 2026-09-01
