Adding UUIDs to existing Laravel models.

April 8, 2021

On one of the projects I'm working on we've decided to switch to using UUIDs as our publicly facing identifiers (in routes, API requests, etc). The motivation behind this is that the business does not want to competitors to know how we a tracking in terms of business metrics, which can sometimes be deduced from the changes in integer ids.

We've got a bit of a problem though. Every single record in our database is currently identified by an integer ID. How can we swap that over without breaking our data?

Luckily, Laravel allows us to set the getRouteKeyName method on our model. This overrides the default primary key on the table and tells laravel to look up route bindings by UUID, not the integer id. I added this to a trait with a method to set the UUID on newly created objects to save having to duplicate the code across all of our models.

trait UsesUuid
{
    public static function bootUsesUuid(): void
    {
        static::creating(function ($model) {
            $model->uuid = Str::uuid();
        });
    }

    public function getRouteKeyName(): string
    {
        return 'uuid';
    }
}

Next up, we need to actually add the UUID column to all of our database tables and populate it with an automatically generated UUID. A quick migration would be easy for a single table, but since we've got 20 plus models to add UUIDs to, I decided to automate it by creating a base migration that does each step for us. For example, here's what I ended up with to migrate 4 different tables.

class AddUuidToUserTables extends AddUuidMigration
{
    public const MODEL_CLASSES = [User::class, Team::class, Policy::class, Control::class];
}

Diving into the UUID migration

To migrate each table, we need to execute a few steps in order:

  1. Add a UUID column to the table, making it nullable (since the existing models don't have any UUIDs yet)
  2. Iterate over all the models, generating a UUID for each one and persisting it.
  3. Update the UUID column to NOT NULL, ensuring that all future rows have a UUID.

To do this, we loop over each one of the model classes defined int the MODEL_CLASSES constant. A column is created on the table and set to nullable.

We then loop over each row, generating a UUID and persisting it. We do this in chunks of 1000 to ensure that we don't accidentally run out of memory, and also consider soft deleted records (we're going to add a NOT NULL constraint to the table, so every single row need a UUID).

Once all the UUIDs have been applied, we update the column to add the constraint, and move on to the next model.

class AddUuidMigration extends Migration
{
    public const MODEL_CLASSES = [];
    public const UUID_FIELD = 'uuid';
    public const UUID_FIELD_AFTER = 'id';
    public const UUID_UPSERT = true;

    public function up(): void
    {
        foreach (static::MODEL_CLASSES as $classname) {
            /** @var Model $model */
            $model = new $classname;

            Schema::table($model->getTable(), function (Blueprint $table) {
                $col = $table->uuid(static::UUID_FIELD)->after(static::UUID_FIELD_AFTER)->index();

                if (static::UUID_UPSERT) {
                    $col->nullable();
                }
            });

            if (static::UUID_UPSERT) {
                $query = $model->newQuery();

                if (method_exists($query, 'withTrashed')) {
                    $query->withTrashed();
                }

                $query->chunk(1000, function (Collection $chunk) {
                    $chunk->each(function (Model $model) {
                        $model->uuid = Str::uuid();
                        $model->save();
                    });
                });

                Schema::table($model->getTable(), function (Blueprint $table) {
                    $table->uuid(static::UUID_FIELD)->after(static::UUID_FIELD_AFTER)->nullable(false)->change();
                });
            }
        }
    }

    public function down(): void
    {
        foreach (self::MODEL_CLASSES as $classname) {
            /** @var Model $model */
            $model = new $classname;

            Schema::table($model->getTable(), function (Blueprint $table) {
                $table->dropColumn(static::UUID_FIELD);
            });
        }
    }
}

Wrapping up

After testing on development, we noticed a bit of an issue. It's quite slow to generate UUIDs and insert them one by one, especially on massive tables. Unfortunately, MySQL doesn't have a way to generate V4 UUIDs (random), only V1 UUIDs (time based) which are not suitable for our purpose.

I thought I'd share this journey as it saved a ton of time my end, and hopefully it helps someone else out there in a similar spot.

Feel free to comment or reach out with any questions :)