mtvs/eloquent-hashids
Laravel package that integrates Hashids with Eloquent models, letting you hide sequential IDs behind short, unique hashids. Add the HasHashid trait to expose a hashid attribute, query models by hashid, and use hashids in route model binding while keeping your database keys unchanged.
Install the package and pull in its config:
composer require mtvs/eloquent-hashids
php artisan vendor:publish --provider="Mtvs\EloquentHashids\EloquentHashidsServiceProvider"
You get config/hashids.php, which holds named connections. Each connection is just a salt/length/alphabet tuple — the same shape the underlying hashids/hashids library expects:
return [
'default' => 'main',
'connections' => [
'main' => [
'salt' => env('HASHIDS_SALT', 'change-me'),
'length' => 0,
'alphabet' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
],
],
];
The first use case is hiding auto-increment IDs from URLs. Add the trait to a model (v2.x naming — HasHashId; v1.x used HasHashids, see Gotchas):
use Mtvs\EloquentHashids\HasHashId;
class Post extends Model
{
use HasHashId;
}
That gives you a computed hashId attribute and static lookups:
$post->hashId; // "k5vXb2"
Post::findByHashId('k5vXb2'); // Post|null
Post::findOrFailByHashId('k5vXb2');
Nothing is stored in the database — the value is encoded from $post->getKey() on access and decoded on lookup. There is no migration step, which is the main reason this package is a five-minute integration rather than a schema change.
To get hashids into route model binding, add the routing trait:
use Mtvs\EloquentHashids\HasHashId;
use Mtvs\EloquentHashids\HashIdRouting;
class Post extends Model
{
use HasHashId, HashIdRouting;
}
route('posts.show', $post) now emits /posts/k5vXb2, and posts/{post} type-hinted controllers resolve the model automatically. Do this in a fresh project or a project where you control all inbound links — see Gotchas.
Per-model salts. Don't share one salt across unrelated models if you care about link opacity. Point each model at its own connection:
class Invoice extends Model
{
use HasHashId, HashIdRouting;
public function getHashIdConnection(): string
{
return 'invoices';
}
}
Or skip config entirely and compute the salt from tenant context, which is the usual multi-tenant answer:
protected function getHashIdSalt(): string
{
return tenant()->id . ':invoices';
}
Explicit route binding instead of the trait. When you need scoping or custom 404 behaviour, register the binding yourself and skip HashIdRouting:
Route::bind('post', fn (string $value) => Post::where('published', true)
->findOrFailByHashId($value));
This is the least surprising setup: binding logic is visible in RouteServiceProvider rather than hidden in a trait's resolveRouteBinding override.
API output. Hashids are perfect for $appends, since they're derived:
protected $appends = ['hash_id'];
public function getHashIdAttribute(): string
{
return $this->hashId;
}
Keep id out of the payload if the whole point is not leaking record volume — shipping both defeats it.
Bulk lookups. There's no whereIn on a computed attribute, so decode first:
$ids = collect($hashids)->map(fn ($h) => Hashids::connection('main')->decode($h)[0] ?? null);
Post::whereIn('id', $ids->filter())->get();
Boundary discipline. The workable convention: hashids exist only at the HTTP edge (routes, JSON responses, form values). Inside services, jobs, events, and DB queries, use integers. Convert once on the way in, once on the way out. This keeps polymorphic relations, whereHas, and queue payloads completely untouched by the package.
Changing the salt breaks every existing URL. The salt is the entire mapping. Rotate it and every previously bookmarked link 404s, silently, with no error to trace. Treat it as a permanent secret; changing length only pads the output and is comparatively safe.
Same salt ⇒ same hash for the same ID across models. With one shared connection, Post#1 and User#1 both encode to k5vXb2. That's harmless while binding is scoped per model, but it becomes a real bug the moment a hashid leaks from one resource type into another (a link in an email, a shared "next record" URL). Per-model connections avoid it.
The v1/v2 naming split bites on copy-paste. v2 renamed HasHashids → HasHashId, HashidRouting → HashIdRouting, hashid → hashId, findByHashid → findByHashId. Most blog posts and Stack Overflow answers are v1. If you get Trait "…HasHashids" not found, check composer.lock and the package's own README rather than trusting the snippet.
Don't assume getRouteKeyName() works like a column. hashId is not a database field, so Laravel's default implicit binding (which builds a where clause) would fail. The routing trait overrides resolveRouteBinding specifically to intercept this. If you override resolveRouteBindingQuery or getRouteKeyName() yourself, you take that override off the critical path and lookups start returning 404s or throwing on a missing column. Extend the query inside the trait's hook rather than replacing the method.
Numeric IDs still work if you don't guard them. A request to /posts/1 won't match a hashid and typically falls through to a 404 — verify this. If your model also has a numeric-friendly fallback somewhere, you've reintroduced the enumeration problem the package was meant to solve.
config:cache freezes the salt. Production deploys that run php artisan config:cache will keep the old salt until the cache is rebuilt. A deploy that changes HASHIDS_SALT without clearing config leaves the app serving a mix of valid and invalid links depending on process age.
Testing. Encode in factories and assertions rather than hardcoding strings, so a salt change doesn't cascade into fifty failing tests:
$this->get(route('posts.show', ['post' => $post->hashId]))->assertOk();
Debugging a bad hashid is just a decode: Hashids::connection('main')->decode($value) returns [] for invalid input and a single-element array for valid. Log both the hashid and the decoded ID at the binding boundary — it makes production 404 reports diagnosable in one line.
How can I help you explore Laravel packages today?