roukmoute/hashids-bundle
Symfony bundle integrating Hashids to encode and decode numeric IDs into short, unique, obfuscated strings. Configure salt, length, and alphabet, then use services or Twig filters to hide database IDs in URLs.
roukmoute/hashids-bundle is a Symfony bundle, so in a Laravel codebase you almost never install it directly. What you actually consume day-to-day is the underlying hashids/hashids library, plus the patterns this bundle popularises: a single configured hashids service, and an argument resolver that transparently converts a hashed route segment back into a real entity/ID. This v4.0 release only changes the second half of that (the resolver), so the patterns are what matter to you.
First steps in a Laravel app:
composer require hashids/hashids (the bundle's composer.json on v4.0.0 requires Symfony 6.2+ / PHP 8.1+, so pulling the bundle into Laravel isn't a thing).// config/hashids.php
return [
'default' => [
'salt' => env('HASHIDS_SALT'),
'min_length'=> 12,
'alphabet' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
],
];
$this->app->singleton(HashidsInterface::class, fn () => new Hashids(
config('hashids.default.salt'),
config('hashids.default.min_length'),
config('hashids.default.alphabet'),
));
/orders/9xK2mQpLwR instead of /orders/1042, and "id" => "9xK2mQpLwR" in resources.If you are running a Symfony 6.2+ service next to your Laravel app, the mapping of concepts is: hashids service → your singleton binding, #[Hashids] route attribute → route model binding / form-request normalisation.
Eloquent cast + getRouteKey() — the highest-leverage pattern, and the closest analogue to the bundle's resolver:
class HashidCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
return $value === null ? null : app(HashidsInterface::class)->encode($value);
}
public function set($model, $key, $value, $attributes)
{
return [$key => app(HashidsInterface::class)->decode($value)[0] ?? $value];
}
}
Pair it with getRouteKey() returning the hashed value, and resolveRouteBinding() decoding it back.
Route model binding for endpoints that aren't model-backed:
Route::bind('order', function (string $value) {
$id = app(HashidsInterface::class)->decode($value)[0] ?? null;
abort_if($id === null, 404);
return Order::findOrFail($id);
});
Request normalisation — the Laravel equivalent of the bundle's argument resolver. Decode in prepareForValidation() so downstream validation rules and controllers see raw integers:
protected function prepareForValidation(): void
{
$this->merge([
'order' => app(HashidsInterface::class)->decode($this->route('order'))[0] ?? null,
]);
}
Validation rule for inbound hashes (HashidExists / ValidHashid) — fails fast with a 422 instead of a 500 from findOrFail on garbage input.
API resources — encode in toArray(), never expose raw IDs. Keep the encode in one place so a future salt rotation is a one-line change.
Multiple connections / salts — mirror the bundle's multi-connection config with named bindings (hashids.public, hashids.internal). Public-facing tokens and internal job references should never share a salt.
Testing helper — a hashid(1) test helper or factory state so tests read get("/orders/".hashid($order->id)) rather than hard-coding opaque strings.
If you forked the bundle's resolver into a Laravel package, mirror the v4 change: implement the new value-resolver contract and keep the class name stable so decorators keep working.
v4.0 is a breaking change, but scoped. The migration from ArgumentValueResolverInterface to ValueResolverInterface means:
HashidsValueResolver (custom resolvers, compiler passes, decorates: service definitions) breaks on a signature-level change, not silently — but it breaks at container compile time, which is easy to miss in CI if you only run unit tests.composer.json doesn't pin php, run composer why-not php 8.0 before bumping.decode() returns [], not null. This is the single most common production bug. $hashids->decode($bad)[0] throws "Undefined array key 0" and 500s on any malformed URL. Always ?? null and abort(404).
Salt is configuration, not encryption. Hashids is reversible and the output space is enumerable. Never use it as an authorisation token, signed URL substitute, or password reset secret. Use URL::signedRoute() for that.
Salt rotation is a breaking change for users. Every URL in emails, sitemaps, invoices, QR codes and third-party integrations dies at once. Treat the salt like an app key: set it once, store it in the secret manager, and if you must rotate, keep the old instance bound as hashids.legacy for a decode-only grace period.
Alphabet constraints. Minimum 16 unique characters, no spaces. If you customise it, remember route regex constraints — the default alphabet is alphanumeric, but if you introduce - or _ and your route is ->where('id', '[A-Za-z0-9]+'), decoding never runs. Keep the default unless you have a reason.
Environment drift is the usual culprit for "links work in staging, 404 in prod": different HASHIDS_SALT between environments, or a trailing newline from a pasted env var. trim() your env value.
Unsaved models. $model->id is null before save(), so the cast returns null and your URLs render /orders/. Guard getRouteKey() or generate URLs after persist only.
Extension point — bind your own implementation of the encoder interface in the container and alias it; in Symfony this is HashidsInterface service aliasing, in Laravel it's $this->app->bind(). Keep the interface narrow (encode/decode) so you can swap in a different obfuscator (Sqids is the actively maintained successor to Hashids) without touching call sites.
How can I help you explore Laravel packages today?