pejeio's avatar
Level 1

Best Practices for Non-Deterministic IDs in Laravel

Hi everyone,

I'm working on a large-scale project using Laravel and I need some advice on implementing non-deterministic IDs for one of our models. The primary goal is to prevent end users from guessing the next ID or deducing the total number of records.

I'm aware that Laravel supports UUIDs through the HasUuids trait, which seems like a good solution for ensuring non-deterministic IDs. However, I'm concerned about potential performance issues given that our database will be quite large, exceeding 3,000,000 records. From my understanding, using UUIDs could introduce overhead compared to using integers, especially when it comes to indexing and querying.

Has anyone faced a similar situation or have insights on the best approach to take?

0 likes
15 replies
Snapey's avatar

keep your big integer ids as primary key, and use uuid or hashid in your routes.

1 like
rodrigo.pedra's avatar

For huge datasets, I prefer using ULID. They are time-based and monotonic, so indexing works well.

You can read more about ULID here: https://github.com/ulid/spec

Laravel has native ULID generation support, just use Str::ulid(). Laravel also has with a HasUlids trait that works the same as the HasUuids one.

rodrigo.pedra's avatar

An additional note, just as @snapey suggested I keep my primary and foreign keys as integers, and have the ULID in a separate column for routing.

I still prefer ULID over UUID due to index performance on huge datasets.

When using this mixed approach, be sure to override the HasUlids or the HasUuids traits uniqueIds() method to provide the column name which holds this routing id.

pejeio's avatar
Level 1

Thanks for the wonderful suggestions, guys!

@snapey I was thinking this as well. The only downside I see is when performing basic Eloquent operations like Model::find($id). It would be more convenient if it used the UUID or HashID column under the hood. Without making the UUID column the primary key in the database. Currently, I would need to do something like Model::where('uuid', $id) instead. Do you think this approach is feasible?

@rodrigo.pedra I will definitely give ULID a look. Thanks for the suggestion!

Snapey's avatar

@pejeio but you would never need to deviate from model::find($id) because all your internal operations would be using integers.

Setup route model binding resolution to use the uuid column and your external urls can use one scheme and all your internal relationships use id.

1 like
martinbean's avatar

@pejeio There are many solutions for non-deterministic IDs: UUIDs, ULIDs, Sqids (formerly Hashids), etc. I personally like ULIDs because they’re alphanumeric and unguessable, but can still be sorted.

However, I'm concerned about potential performance issues given that our database will be quite large, exceeding 3,000,000 records. From my understanding, using UUIDs could introduce overhead compared to using integers, especially when it comes to indexing and querying.

This is why you keep using integer primary keys, but then use the alternative identifier (i.e. UUIDs) in URLs, instead of using something like UUIDs as your primary and foreign keys. So most of my database tables will have the regular integer primary key, and then a second column to hold the ULID:

Schema::create('items', function (Blueprint $table): void {
    $table->id();
    $table->ulid()->unique();
    // Other columns...
});

I can then use the ulid column for retrieving models when using route–model binding:

Route::get('items/{item:ulid}', [ItemController::class, 'show'])->whereUlid('item');
class ItemController extends Controller
{
    public function show(Item $item)
    {
        // Return response...
    }
}
1 like
pejeio's avatar
Level 1

Hi @martinbean,

Looks great! How do you handle generating the ULID when a new record is inserted into the database? Is it something like this?

app/Models/MyModel.php

protected static function boot()
    {
        parent::boot();

        static::creating(function ($model) {
            $model->ulid = Str::ulid();
        });
    }
martinbean's avatar
Level 80

@pejeio You can either create your own trait, or use the one Laravel provides:

use Illuminate\Database\Eloquent\Concerns\HasUlids;

class Foo extends Model
{
    use HasUlids;

    public function uniqueIds()
    {
        return [
            'ulid',
        ];
    }
}

I personally don’t like having to define the uniqueIds method in each model I want to use ULIDs, so end up wrapping it in my own trait:

namespace App\Models\Concerns;

use Illuminate\Database\Eloquent\Concerns\HasUlids;

trait HasUlid
{
    use HasUlids;

    public function uniqueIds()
    {
        return [
            'ulid',
        ];
    }
}

I can then just apply this trait to models and it automatically fill ulid columns with a ULID value on creation:

use App\Models\Concerns\HasUlid;

class Foo extends Model
{
    use HasUlid;
}
1 like
pejeio's avatar
Level 1

Thanks @martinbean

Maybe one last question. When I call the "show" API route, the response looks like this:

{
    "ulid":  "01j0y0zhpv53k7pra61mdv1f0v"
    ...
}

From the perspective of the API user, this field should be called "id". What's the cleanest solution for this? Would you use Eloquent API Resources to achieve this?

min-mahatara's avatar

Note: I used ChatGPT to help structure parts of this explanation, but the approach described is from my actual production experience.

Laravel 12.x supports UUIDv7 out of the box (and Laravel 11.x introduced it). However, if you want to use both UUID7 and ULID together on the same model, you'll need a custom solution.

✅ Internal vs. External Identifiers

Using UUIDv7 for primary keys (e.g., id) is an excellent choice—especially for scalable applications. UUIDs are globally unique and make tasks like data merging and system interoperability much safer by minimizing the risk of key collisions.

One of the key advantages of using UUIDs as internal identifiers is that you can perform direct database operations without relying solely on APIs. This encourages cleaner, more decoupled API design.

However, UUIDs (especially v7) are long and not user-friendly. Therefore, I strongly recommend not exposing UUIDs to end users. Instead, use ULIDs for public-facing IDs.

For example:

  • Use id (UUIDv7) internally for primary keys, foreign keys, and unique constraints.
  • Use public_id (ULID) externally in URLs, APIs, and UI displays.

This separation protects internal infrastructure and keeps your system's internals abstracted from external consumers.

🛠 CRUD Operations and Routing

Always expose and interact with the public_id for all CRUD operations. This adds a slight abstraction layer but helps shield internal identifiers and improves system design.

Laravel allows you to customize route model binding to use public_id:

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

⚖️ Performance Considerations

It's true that UUIDs and ULIDs can be slightly slower than auto-incrementing integers due to their size and index performance. However, the benefits—global uniqueness, scalability, and flexibility—far outweigh these drawbacks, especially for distributed systems.

UUIDv7 offers the additional benefit of lexical sortability. Because it encodes a timestamp in milliseconds, newly generated UUIDs are naturally ordered. This improves index locality and query performance compared to UUIDv4.

🔄 Data Portability

Another major benefit of UUIDs: they're ideal for syncing data across environments or services. Since UUIDs are unique across systems, there's very little risk of collision when importing or migrating data.


✅ Recommended Strategy

  • Internal IDs (id, FK, PK, UK): Use UUIDv7. Never expose.
  • External IDs (public_id): Use ULID. Always expose.

🧩 Custom Trait: HasUuidsAndPublicId

Since Laravel does not currently support using both HasUuids and HasUlids traits together in a single model, you can define a custom trait to achieve this:

Use this trait on your models:

class User extends Model
{
    use HasUuidsAndPublicId;

    protected $table = 'users';
}

⚠️ Note: Trying to use both HasUuids and HasUlids traits simultaneously, like this:

use HasUuids, HasUlids;

will not work, as Laravel does not currently support combining both traits on the same model.


Final Thoughts

For scalable Laravel applications:

  • Use UUIDv7 for internal keys.
  • Use ULIDs for external identifiers.
  • Encapsulate this logic in custom traits like HasUuidsAndPublicId.

This strategy balances performance, security, and maintainability, and is already adopted by many large-scale systems.

newbie360's avatar

Instead of expose that long ugly string to client, i will make it shorter

<?php

declare(strict_types=1);

if (! function_exists('genHash')) {
    function genHash(): string
    {
        return hash('crc32b', Str::uuid()->toString());
    }
}
genHash(); // '1ddfdbf6'
Snapey's avatar

@newbie360 if you reduce the length of the ulid with hashing, you also increase the chance of collisions?

newbie360's avatar

@Snapey Oops, you are right 32-bit output space only 4.3 billion possible hash values

i forgot used it only to generate hardcoded string for html markup

<wire:key="1ddfdbf6">

My bad!!

Please or to participate in this conversation.