Masking IDs in URLs using hashids in Laravel

By default a URL generated by a Laravel app will contain the ID of a model like this https://app.name/users/1 where 1 is the ID of the item. Often this is absolutely fine, but sometimes you might want to hide it (or obfuscate it). The two main use cases for this I've come across so far are:

  1. Security - If you show the ID of all of your models, a nefarious user might be able to use it to their advantage.
  2. More professional - By masking the ID, you hide the number of items in the database. How many users there are in your app, for example, might indicate how popular your app is. If I can see that the last user to sign up has the ID of 5, it's likely that your app isn't very popular yet.

What we want to achieve is a URL where the ID is masked like this:

https://app.name/users/RqB3N

You can do this with a library called hashids and a bit of Laravel knowhow.

Installing dependencies

There are many ports of the hashids library and luckily there's one for PHP. There's even a wrapper for Laravel, which is the one we're going to be using. Let's install it via composer:

$ composer require vinkla/hashids

Next we need to add the facade to our list of facades in config/app.php in order to use it:

'Hashids' => Vinkla\Hashids\Facades\Hashids::class

While you're there, if you're using Laravel <5.5 you'll also need to add the service provider to your list of providers in config/app.php too. If you're using 5.5 or newer, you don't need to worry about this since it's detected and registered automatically.

Vinkla\Hashids\HashidsServiceProvider::class,

Finally for our setup, let's publish the configuration since we'll be changing that shortly:

$ php artisan vendor:publish --provider="Vinkla\Hashids\HashidsServiceProvider"

Models, controllers, and routing

At this point I'm assuming that we already have our models, controllers and routing set up. There's nothing special about the controllers or the models (apart from the trait we add later). If you haven't done it already, you can go ahead and set those up now.

In your routing, you need to make sure you're using route model binding, where your route might look something like this:

Route::get('users/{user}', function (App\User $user) {
    // ...
});

Or

Route::resource('users', 'UserController');

Making connections

In our configuration, found at config/hashids.php, we need to add a connection for each of the models where we'd like to use hashids. By doing this, we can set a different salt for each connection, meaning that the hash generated for User with the ID of 1 is different from Product with the ID of 1.

If we use the same connection for both, they'll both have the same hashid. While there's no technical problem with them both having the same hashid, we may still be revealing information about the app if you can infer what one of the underlying IDs might be.

The salt can be anything you like, as long as it's unique for each item. To keep things simple I tend to use the model name plus a random MD5 hash.

We can adjust the starting length for the hashid at this point too.

'connections' => [
    \App\User::class => [
        'salt' => \App\User::class.'7623e9b0009feff8e024a689d6ef59ce',
        'length' => 5,
    ],
    \App\Product::class => [
        'salt' => \App\Product::class.'7623e9b0009feff8e024a689d6ef59ce',
        'length' => 2,
    ],
],

Setting the route key for our models

With route model binding we can customise the key that's used by overriding the getRouteKey method on the model. By default, this is the ID of our model, but we actually want this to be the hash of the item's ID encoded by the hashids library. Because we already know that we're going to be using this on at least 2 models (User and Product), it makes sense to abstract it and put it into a trait.

Create a new file in app/Http/Traits called Hashidable.php with the following contents:

namespace App\Http\Traits;

trait Hashidable
{
    public function getRouteKey()
    {
        return \Hashids::connection(get_called_class())->encode($this->getKey());
    }
}

We now need to use this trait on our models. In app/User.php make sure you import the trait and use it:

use App\Http\Traits\Hashidable;

class User extends Authenticatable
{
    use Hashidable;

    // ...
}

Let's not forget to do this for our other model too, Product.

At this point, any URL which is generated for our User or Product models by our app will contain the hashid instead of the model ID. Perfect. But there's more! We need to tell Laravel what to do with the hashid when it sees it in the URL. At the moment it's going to try to look for the hashid in the ID column of the database, but we need to decode it first.

Decoding and binding

Logically, what we want to do at this point is to decode the hashid back to the model ID and then return the model instance. We can do exactly this in app/Providers/RouteServiceProvider.php. In the boot method we can tell Laravel what to do when it encounters an instance of a particular model with regards to routing.

public function boot()
{
    parent::boot();

    Route::bind('user', function ($value, $route) {
        return $this->getModel(\App\User::class, $value);
    });

    Route::bind('product', function ($value, $route) {
        return $this->getModel(\App\Product::class, $value);
    });
}

When encountering an instance of either User or Product, we call a reusable method getModel. We're accepting 2 parameters here: the model and the routeKey. We can convert the routeKey back to the ID by decoding it using the hashids connection for the model. Finally we return the instance of the model.

private function getModel($model, $routeKey)
{
    $id = \Hashids::connection($model)->decode($routeKey)[0] ?? null;
    $modelInstance = resolve($model);

    return  $modelInstance->findOrFail($id);
}

That's all there is to it! Now if you visit the URL https://app.name/users/RqB3N, you'll see the user with the ID of 1. Neat!

If you want to add more models, you need to create a new connection in config/hashids.php, add the hashidable trait to your model and create a new binding in the boot method for the model in app/Providers/RouteServiceProvider.php.