How to create non-sequential unique IDs based on a numeric value (YouTube or URL Shortener style) in Symfony 3

If you have some table on your database, whose primary key is a number, tipically in Symfony with a CRUD view, you can see this register in the browser with some kind of URL that shows the ID in the URL e.g http://project/users/12. To make this easy to understand, let's imagine that you have some web system without protection (no user system or role protection of any kind). In this system, you can create your personal profile and save information that no one else should see. Your profile will be accessible at:

http://imaginarysystem.com/profiles/2

In this case your profile is the identified with the number 2 (your ID in the user table of the app is 2) and just by accessing to that URL, your information is accessible. Now, what if you change the number of the profile from 2 to 123:

http://imaginarysystem.com/profiles/123

Then you can see the information of the user number 123 ! Which isn't good at all because in our system the profile shouldn't be accesible for anyone else. Obviously, a normal system should provide security to see wheter the user that access the profile is the same as the owner, however this is just an example that you can understand. Now, it would be nice to use an alphanumeric string to identify the ID in your system (at least that is not so easy to read and create for humans) like:

http://imaginarysystem.com/profiles/xzq85hj

This alphanumeric string is someway the same number (2) but "masked", then it can be "unmasked" once the user calls the URL as http://imaginarysystem.com/profiles/xzq85hj where xzq85hj should be converted by our script to 2 (as every database performs queries really quick with number instead of strings). Note that you shouldn't use this library as a security tool and do not encode sensitive data. This is not an encryption library.

This style is followed by YouTube or any other kind of URL Shorteners services like TinyURL, Google URL Shortener and others. Besides it provides protection for evil crawlers that just want to copy the content of your website by trying all possible numbers in the URL e.g http://imaginarysystem.com/profiles/{the number of the loop here e.g 3,4,5,6}.

Implementation

Although it sounds pretty easy to implement as we would only need to hash a number and that's it, if you want to implement it correctly in the Symfony style, then it will take some time. Follow these steps:

1. Install the hashids library

The first thing you need to do is to install the hashids library in your Symfony project. Hashids is small PHP library to generate YouTube-like ids from numbers. Use it when you don't want to expose your database ids to the user.

To install this library, open a terminal, switch to the directory of your project and execute the following command:

composer require hashids/hashids

Alternatively, you can modify your composer.json file and add the dependency manually:

{
    "require": {
        "hashids/hashids": "^2.0"
    },
}

Then run composer install. Once the installation finishes, you'll be able to use the library in your project. If you want more information about this library, please visit the official repository in Github here.

2. Usage in Controllers

The hashids library works in the following way, you create a new instance of Hashids whose constructor expects a randomness or entropy string as first argument, based on this string your hash will be generated, besides you can specify which length should have the string by providing the second parameter. See the following example of masking:

<?php 

use Hashids\Hashids;

// Set padding of string to 10 and entropy string "My Project"
$hashids = new Hashids('My Project', 10);
echo $hashids->encode(1); // jEWOEVpQx3

// Set padding of string to 10 and entropy string "My Other Project"
$hashids = new Hashids('My Other Project', 10);
echo $hashids->encode(1); // NA4ByeBWQp

Then if you want to convert the hash into its numeric representation (the string to the number 1 again):

<?php 

use Hashids\Hashids;

// Set padding of string to 10 and entropy string "My Project"
$hashids = new Hashids('My Project', 10);
echo $hashids->decode("jEWOEVpQx3")[0]; // 1

// Set padding of string to 10 and entropy string "My Other Project"
$hashids = new Hashids('My Other Project', 10);
echo $hashids->decode("NA4ByeBWQp")[0]; // 1

Note that the decode process returns an array, therefore we access the result by the first and usually unique index.

Now, in your Symfony project, you won't obviously create a new instance of the class and then write the same entropy string and padding value on every controller where you need it. Instead, we recommend you to create a new Symfony service for it. In this service we will handle the Hashids library and we'll create 2 methods, one will be used to create the hash and the other will translate the hash into its numeric representation. The entropy string that we'll use at the generation of the mask in the service, will be a custom string declared in your parameters.yml file and a padding number (to define the length of the generated mask) however you can define these values directly in your class if you want.

As first step we are going to define your Entropy String and the length of the generated hash as custom parameters in your parameters file (app/config/parameters.yml) or according to the best practices in your config.yml file with the following identifiers:

Important

These values will be always the same, you can't change them once you set them in your application, otherwise the URL masks will change as well.

# Add 2 new parameters
parameters:
    url_hasher_entropy: randomstring
    url_hasher_padding: 10

Remember that you can (not necessarily) set those parameters in the file called parameters.yml.dist, which stores the canonical list of configuration parameters for the application (read more about this here). Once the new parameters has been created, proceed to create a new Symfony service, in this case create a class identified as IdHasher (IdHasher.php) and place the following code inside (change the namespace according to your app):

Note

This is a basic implementation of Hashids (encode and decode), you can make it even more complex if you want or need to.

<?php

// The namespace needs to be changed according to your needs
namespace AppBundle\Services;

use Symfony\Component\Config\Definition\Exception\Exception;
use Hashids\Hashids;

class IdHasher{
    private $HashIdsInstance;

    /**
     * The constructor expects the entropy string and the padding for the Hashids class
     * parameters declared as url_hasher_entropy and url_hasher_padding in parameters.yml
     *
     * @param $entropyString {String}
     * @param $hashPadding {Integer}
     */
    public function __construct($entropyString, $hashPadding)
    {
        $this->HashIdsInstance = new Hashids($entropyString, $hashPadding);
    }

    public function encode($number){
        return $this->HashIdsInstance->encode($number);
    }

    public function decode($hash){
        $result = $this->HashIdsInstance->decode($hash);
        
        return empty($result) ? null : $result[0];
    }
}

In this example, the file was created at symfonyproject/src/AppBundle/Services. Note that we'll inject 2 static parameters in our service. Now that the service exists, we just need to register it and inject the previously created custom parameters. Open the services.yml file of your project and register the service:

services:
    # Name of the service id_hasher
    id_hasher:
        # Path of the previously created class
        class: AppBundle\Services\IdHasher
        # Inject the custom parameters as arguments for the IdHasher class
        arguments: ["%url_hasher_entropy%" , "%url_hasher_padding%"]

Our service can be required with the id_hasher identifier, save the changes, clear the cache of your project and the service should be now registered, you can use it from your controllers like:

<?php 

// Retrieve our created hasher service
// $this->get = the service container
$hasherService = $this->get("id_hasher");

// With our secret generates : bJ0d4Wn3x8 (will vary on your project as the secret is different)
echo $hasherService->encode(1);

// Decode the hash generated by the encode function 
echo $hasherService->decode("bJ0d4Wn3x8"); // 1

Pretty easy right ? With this you'll be able to generate and use the hashes of the numeric values as you wish, to generate custom routes etc. If the decode function returns null, it means that the providen "hash" can't be translated to a valid number.

Example in controllers

Now let's implement something that you will understand easier. We will create a VideosController, this controller has only 2 routes, one route will convert the numeric ID retrieven in the URL to its hash (process route) and it will automatically redirect to the show view:

<?php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class VideosController extends Controller
{
    public function generateAction($id){
        // Retrieve our created hasher service
        $hasherService = $this->get("id_hasher");

        // Generate hash of the integer number
        $hashId = $hasherService->encode($id);

        // Send a response
        // http://demo/app_dev.php/videos/generate/1 => generates hashId bJ0d4Wn3x8
        // return new Response("The hash for your video with numeric id $id is : ".$hashId);

        // Or you can make it interesting and generate a route directly to the showAction (http://demo/app_dev.php/videos/show/{the generated hash})
        return $this->redirectToRoute('videos_show', [
            'hashId' => $hashId
        ]);
    }

    public function showAction($hashId){
        // Retrieve our created hasher service
        $hasherService = $this->get("id_hasher");

        // Convert the hash to its integer representation
        $numericId = $hasherService->decode($hashId);

        // Send a response
        // http://demo/app_dev.php/videos/show/bJ0d4Wn3x8 => prints "1" as numeric id again :)
        return new Response("The number of your video with id $hashId is : ".$numericId);
    }
}

The routes of the following controller are defined in the routing.yml file with the following structure:

videos_generate:
    path:      /videos/generate/{id}
    defaults:  { _controller: AppBundle:Videos:generate }

videos_show:
    path:      /videos/show/{hashId}
    defaults:  { _controller: AppBundle:Videos:show }

The way this controller works is pretty simple, you access the generate action with the following example URL http://demo/videos/generate/189. Now once the user visits the URL, he will be automatically redirected to the show view that will convert the 189 into its hash, that in our case was http://demo/videos/show/bJ0d4rzE3x.

How you handle the hashes to generate routes, to redirect or the decision of storing the hashes in the database (not so efficient) is totally up to you.

3. Using the hashes in Twig

If you were wise and decided to don't store the generated hashes by the service in the database as that's a little bit unnecessary, you have probably already thought, How do I create routes with the hash on views? Great question ! As you see, till this moment we can only generate the hashes on controller or with PHP code in your project but not in Twig. Therefore you need to create a new Twig extension that expose new functions namely url_id_hasher_encode and url_id_hasher_decode (you can change their name if you want).

The extension that you need to create is really simple, we just need to require the same service previously created (step #2) in the Twig extension and write a little wrapper for the encode and decode function in twig. We will create just 2 Twig functions that expects as unique argument the string or number to encode or decode (depending on which function you use).

To create the extension, create a new file namely TwigExtensions.php with the following content:

<?php
// The namespace according to the bundle and the path
namespace AppBundle\Extensions;

use Symfony\Component\DependencyInjection\Container;
// Include the IdHasher class to instantiate in the constructor
use AppBundle\Services\IdHasher;

// The Name of your class
class TwigExtensions extends \Twig_Extension
{
    protected $id_hasher;

    public function getFunctions()
    {
        return array(
            new \Twig_SimpleFunction('url_id_hasher_encode', array($this, 'urlIdHasherEncode')),
            new \Twig_SimpleFunction('url_id_hasher_decode', array($this, 'urlIdHasherDecode'))
        );
    }
    
    public function __construct(IdHasher $hasherService)
    {
        $this->id_hasher = $hasherService;
    }

    /**
     * Declare encode function
     *
     * @param $value {Integer || String} Number to convert into hash
     */
    public function urlIdHasherEncode($value){
        return $this->id_hasher->encode($value);
    }

    /**
     * Declare decode function
     *
     * @param $value {Integer || String} NHash to convert into number
     */
    public function urlIdHasherDecode($value){
        return $this->id_hasher->decode($value);
    }
    
    public function getName()
    {
        return 'TwigExtensions';
    }
}

Note

If you have already a TwigExtensions file available, then you can only copy the functions to hash the numbers and viceversa but don't forget to inject the created service into the Twig extension.

The special on the extension, are the encode and decode PHP function that are registered in Twig as url_id_hasher_encode and url_id_hasher_decode. As you can see, it receives the id_hasher service (created in the step #2) and encodes or decodes a string according to its arguments. Now register the extension in the services.yml file (change the class according to the location of yours):

services:
    twig.extension:
    # the namespace with the name of your twig extension class
        class: AppBundle\Extensions\TwigExtensions
        arguments: 
            service_container : "@id_hasher"
        tags:
            -  { name: twig.extension }

Save changes, clear the cache of your project and now the functions can be used in the Twig views as shown in the following example:

{% extends 'base.html.twig' %}

{% block body %}
    {# Note that according to your custom parameters the hashes will change#}
    {# Prints bJ0d4Wn3x8 #}
    {{ url_id_hasher_encode(1)}}

    {# Prints 1#}
    {{ url_id_hasher_decode("bJ0d4Wn3x8")}}
    
{% endblock %}

And an example of how to generate a route (with the previously registered route videos_show):

{% extends 'base.html.twig' %}

{% block body %}
    {# http://demo/videos/show/bJ0d4rzE3x #}
    {% set LinkUrl = path("videos_show", { 'hashId': url_id_hasher_encode(1) }) %}

    <a href="{{LinkUrl}}">
        Redirect to video !
    </a>
{% endblock %}

Happy coding !

Become a more social person