Nov 11, 2022

Using Hashid With Laravel (instead of UUID)

About database primary key, there are 2 sides fighting: Auto-incrementing integers 🆚 UUID.

I want to discuss my favorite way: using integers internally (because they are small and ordered) and exposing a random string publicly. A typical way is to generate a random string and store it in the database, but you have to be very careful with your indexes.

A better way is to encode the primary key in the random string!

I have built a small demo app to show what I’m discussing in this article.

UUIDs

In most database systems UUID is a native type so it isn't juste "a string" but still, is it as performant as the integer IDs?

  • What version should you use? V4? V6? V7?
  • Should you use ULID?
  • If not ULID or v7, do you order by created_at ?
  • Is it as performant as integers in the long run?

Maybe! 🤷‍♂️ I don’t know much about UUIDs, and I’m not too interested because I have a much better solution.

EDIT: Laravel handles UUID by default now 🚀

Introducing Hashid

Hashid has been renamed Sqids.

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); // mD1
3$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); // rov6GmD1d5MN
3$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:

  1. Importing Hashids\Hashids
  2. Generating hashids per Models
  3. Retrieving Model instance from hashids
  4. 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 in my demo app. This is intentional for the demo but you should put it an environment variable (and a config file).

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

To add the ability for our model to encode their primary key in a hashid, we'll create a trait.
The HasHashId trait adds 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 Authenticatable
2{
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 HasHashid
10{
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_DngB0NV05ev1
2Route::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 ServiceProvider
12{
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.