I’ve recently seen a Twitter discussion about what primary key should your tables use. The conversion tends to focus on default auto-incrementing integer ids vs UUIDs. I always leaned towards incremental Integer IDs because I find it so simple.
The biggest drawback is that you rarely want to show the IDs to the enduser, in the URL for instance. You don’t want to show your customer they’re number 5, working or the 8th thing of your entire product. Not that it matters but nobody likes it.
I have built a small demo app to show what I’m discussing in this article.
UUIDs
One common solution is to keep the auto-increment int ID as the primary key and add store a new column with a UUID. You’ll still need to retrieve entries via their UUID.
Modern database systems handle UUID pretty well now. UUID aren't stored as string but still, I have some reserve.
- What version should you use? V4? V6?
- How do you order? Should you use ULID then?
- Is it as good as integers for complex queries?
If you even want to get rid of the primary ID, is it really as performant to have UUID as primary and foreign keys?
Maybe! 🤷♂️ I don’t know much about UUIDs, and I’m not interested because I have a much better solution.
EDIT: Now Laravel ships with a new feature to use UUID by default 🚀
Introducing Hashid
Hashid is a way to generate short, unique, non-sequential strings from numbers. Despite the name, hashids encode the numbers in the string so you can decode it.
It's ideal to store primary key. Your database only knows about auto-incrementing integer and only the app manage hashids. Publicly, the enduser only see string ids like JMxSCJxl
.
Even better, you can prefix the hashids since they are strings. It makes all hashes unique across the whole system and easily recognisable.
This is exactly how Stripe does it.
Model | Primary key | Hashid |
---|---|---|
User | 985 | user_6ZzyGMgoA4No |
Team | 125 | team_rov6GmD1d5MN |
Thing | 19248753 | thing_x6GQyy1MNGym |
Simple example of how hashid work
The following snippet is a simple example of how hashids work. Note that the decode functions always returns an array. You can store multiple number a single hashid.
Hashids are encoded with a salt so you'll need this salt to decode every hashid.
1$h = new Hashids\Hashids('this is my salt');2$hashid = $h->encode(125); // mD13$id = $h->decode($hashid); // [125]
I find that very short hashids don't look very good. Fortunately, you can specify a minimum length when instantiating the Hashids
object.
1$h = new Hashids\Hashids('this is my salt', 12);2$hashid = $h->encode(125); // rov6GmD1d5MN3$id = $h->decode($hashid); // [125]
Implementing Stripe-like Hashids with Laravel
In the rest of this article, I'll show you how to use hashids in a Laravel application. This includes:
- Importing
Hashids\Hashids
- Generating hashids per Models
- Retrieving Model instance from hashids
- Handling hashids in Route resolutions
I have built a simple demo app to showcase how this works. You can view it live and checkout the repository on GitHub.
Why not use a package?
There are packages available (like deligoez/laravel-model-hashid) but I recommend implementing Hashid yourself, following this guide.
I strongly believe we should keep the number of dependencies as low as possible. Something as critical (and simple, you'll see) as this should be done in the app code.
To be clear, we'll use a package to encode and decode the hashids but I don't want another one for the Laravel glue.
Adding Hashid package and creating a Facade
Vincent Klaiber implemented the hashid algorithm in PHP and maintains the package vinkla/hashids.
1composer require hashids/hashids
As mentioned earlier at example above, you need to always instantiate Hashids\Hashids
with the same salt. We'll need to add the class definition to the container.
Personally, I don't like injecting this type of class in my objects. I feel like this function could (should?) be part of PHP like json_encode
.
I wouldn't inject an object to manipulate JSON, I'd call it directly. I want a Facade for this, it's easy to use, and it feels more Laravel-y.
In the app/Provider/AppServiceProvider.php
file, we'll register the function definition.
1<?php 2 3namespace App\Providers; 4 5use Hashids\Hashids;
6use Illuminate\Support\ServiceProvider; 7 8class AppServiceProvider extends ServiceProvider 9{10 public function register()11 {12 $this->app->singleton('hashid', function () {13 return new Hashids('This is my salt!', 6);14 });15 }16 17 public function boot()18 {19 //20 }21}
About secret salt
The salt is hardcoded and committed. This is intentional. You can move it to a config file if you prefer, but you'll never need it elsewhere.
It could also be an environment variable but this is not a critical token. If leaked, your "attacker" will only be able to know that the user with id rov6GmD1d5MN
is actually the user with primary key 125. There is no security risk.
Next, let's create a Facade using this newly created hashid
singleton. I typically create them in a Support folder: app/Support/Facade/Hashid.php
.
1<?php 2 3namespace App\Support\Facade; 4 5use Illuminate\Support\Facades\Facade; 6 7class Hashid extends Facade 8{ 9 protected static function getFacadeAccessor()10 {11 return 'hashid';12 }13}
Creating HasHashid
trait for our models
Now, we want to add the ability to our model to encode their primary key in a hashid. We'll create a trait in our Models folder add two new dynamic attributes. One is the actual hashid and the other one prepends it with the model prefix.
1<?php 2 3namespace App\Models; 4 5use App\Support\Facade\Hashid; 6 7trait HasHashid 8{ 9 public function getHashidAttribute()10 {11 return static::HASHID_PREFIX.$this->hashid_without_prefix;12 }13 14 public function getHashidWithoutPrefixAttribute()15 {16 return Hashid::encode($this->id); 17 }18}
To add hashid to a model, we'll use this new trait and define the prefix. A prefix is defined in every model using this trait.
1class User extends Authenticatable2{3 use HasFactory, Notifiable;4 use HasHashid; 5 6 const HASHID_PREFIX = 'usr_'; 7 8 // ...9}
1User::first()->hashid; // usr_DngB0NV05ev1
Retrieve Models by Hashids
Next, we want to retrieve models directly via the hashid. The hashid is not stored in the database so we need decode the string to get the primary key.
Following conventions, we'll call this method findOrFailByHashid
and add it to our HasHashid
trait.
1<?php 2 3namespace App\Models; 4 5use App\Support\Facade\Hashid;
6use Illuminate\Database\Eloquent\ModelNotFoundException; 7use Illuminate\Support\Str; 8 9trait HasHashid10{11 public static function findOrFailByHashid($hid) 12 {13 $hash = Str::after($hid, static::HASHID_PREFIX);14 $ids = Hashid::decode($hash);15 16 if (empty($ids)) {17 throw new ModelNotFoundException();18 }19 20 return static::findOrFail($ids[0]);21 } 22 23 public function getHashidAttribute()24 {25 return static::HASHID_PREFIX.$this->hashid_without_prefix;26 }27 28 public function getHashidWithoutPrefixAttribute()29 {30 return Hashid::encode($this->id);31 }32}
1User::findOrFailByHashid('usr_DngB0NV05ev1');
Routing resolution
The Laravel router will resolve your model based on the primary key. You can customise the key but it won't work here. The custom key can only map to a column that exists in the database. Remember that we're not storing the hash in the database.
We will introduce a new parameter name in our route file and tell Laravel how to resolve it.
1// example.com/user/usr_DngB0NV05ev12Route::get('/user/{usr_hashid}', function (User $user) {3 return $user;4});
1<?php 2 3namespace App\Providers; 4 5use App\Models\Thing;
6use App\Models\User; 7use Hashids\Hashids; 8use Illuminate\Support\Facades\Route; 9use Illuminate\Support\ServiceProvider;10 11class HashidServiceProvider extends ServiceProvider12{13 public function register()14 {15 $this->app->singleton('hashid', function () {16 return new Hashids('This HASHID concept is amazing!', 6);17 });18 }19 20 public function boot() 21 {22 Route::bind('usr_hashid', function ($value) {23 return User::findOrFailByHashid($value);24 });25 } 26}
In this case, you'll have to add the route binding for each model using hashids. You can make it more generic and retrieve the model directly from the prefix if you start having a lot of models, but I prefer to keep it this way.
Conclusion
I personally think you get both a both worlds with this hashid thing. Integer as primary/foreign keys are super simple AND you get cool URLs like Stripe.
What do you think? If you have implemented it, please let me know how you did it on twitter or via email.