torann/hashids
Laravel wrapper for Hashids that encodes numeric IDs into short, unique, non-sequential strings and decodes them back, helping hide database IDs in URLs and APIs with configurable salt, length, alphabet, and simple Laravel integration.
Architecture fit
torann/hashids is deliberately thin glue — a service provider, a Hashids facade, and a publishable config array wrapping hashids/hashids. Architecturally that's the right shape for the problem it solves: it's an edge/serialization concern, so it lands at the URL and API-resource boundary with no schema change, no data migration, and a cheap revert. A TPM should treat it as a bounded, reversible change inside one context rather than a platform decision.
Two architectural constraints drive everything:
Also note the ordering constraint: hashids destroys sortability. Any public surface needing range queries or sortable IDs is out of scope by definition.
Integration feasibility
Mechanically trivial: composer require, vendor:publish, a salt in an env var read only inside config/hashids.php. The entire public API is encode(), decode(), encodeHex(), decodeHex(), connection(). One engineer, ~1–2 days for the adapter plus the first surface; ~1 week to roll through several surfaces with redirects and tests.
Effort scales with number of surfaces converted, not with package complexity. Budget by counting touchpoints (models, resources, form requests, redirect routes), and reserve the bulk of the estimate for the legacy-URL tail rather than the encoding itself.
Zero listed dependents means no transitive upgrade drag — and also no community validation. Feasibility on Laravel 11/12 is high, but verify the illuminate/* constraint in composer.json against the deployed PHP floor rather than trusting the "Laravel 12 support" changelog line.
Technical risk The dominant risk is misuse, not malfunction. Ranked:
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| Treated as a security control | High — IDOR exposure assumed fixed when it isn't | Medium-high | State "obfuscation, not authorization" in the ticket; authorization stays server-side |
| Salt rotation without a product plan | High — every outstanding link dies at once | Medium | Written rotation policy + owner + dual-read + comms before adoption |
decode() returns [], not null/false |
Medium — null derefs, 500s | High | Adapter returning ?int; validate shape in form requests → 422 |
| Salt leaked via repo/logs/stack traces | High — full enumeration | Low-medium | Secret manager, no logging, treat as sensitive config |
Fixed-width assumptions (length is a minimum) |
Medium — breaks at volume in UI/regex/schema | Medium | Variable-length strings everywhere; no char(10) |
env() read at runtime → null after config:cache |
Medium — works locally, ships | Medium | Env only in config; boot-time assertion the salt resolves |
Vendor risk is nominal in blast radius: ~54 stars, single maintainer, no dependents, but the fallback is a one-file change if the adapter boundary exists. Price it as "assume no upstream support SLA; own the exit."
Key questions
char(n) columns, {10} regexes, printed labels?config:cache after env changes, and do all environments hold identical salt/alphabet per connection?Stack fit
Native Laravel, auto-discovered, single composer dependency on hashids/hashids plus illuminate support. No infrastructure, no service, no migration. It drops into any 11/12 app as a version bump + a config file + one adapter class, which is exactly the profile you want for a hardening item that shouldn't compete with roadmap work.
Migration path Sequenced by surface risk, not by code layer:
PublicId class (encode(int|string, ?string $connection): string, decode(string, ?string $connection): ?int). Every call site goes through it. This is the reversibility insurance and the only long-term extension point; it's what makes swapping to ULIDs or dropping to the raw library a one-file change.HasPublicId trait overriding getRouteKey() / resolveRouteBinding() on a named list of models. Not all models — every extra encode is indirection with no product payoff.id alongside the encoded one; shipping both defeats the purpose and someone will build against the integer.Explicit non-goals: no hashid columns, no schema migrations, no seeded hashids. The one documented exception is a denormalized public_ref for records whose external reference must survive a salt rotation — a deliberate decision, not a convenience.
Compatibility 3.0.6 adds Laravel 12 as a patch, implying no migration cost for teams already on 3.x — treat it as additive to 11 and check the actual constraint rather than inferring from the release note. Teams on 2.x get nothing here and would need to absorb 3.0.0 breaking changes; do not bundle that into a framework upgrade tranche.
Two cross-environment hazards to bake into the plan: salt and alphabet must match across all environments per connection (drift produces staging-passes/prod-404s), and the test suite must pin HASHIDS_SALT / HASHIDS_LENGTH in phpunit.xml with round-trip assertions rather than literal strings — a random per-run salt makes output untestable and a hardcoded hashid breaks on the first rotation.
Sequencing Slot inside an existing Laravel 12 upgrade or an anti-enumeration hardening tranche — not a standalone project. Order of operations is non-negotiable: salt policy written first, then adapter + tests, then one surface in production, then measure, then expand, then legacy redirect, then retire. Gate each phase on a metric (404 rate, legacy URL hits, decode-failure rate) rather than on a calendar date.
Maintenance
Near-zero code maintenance: the wrapper is three files over a stable algorithm. The real carrying cost is config lifecycle — salt in a secret manager, deploy-time config:cache, per-environment parity, and a boot-time assertion that the resolved salt is non-empty and the alphabet is valid (≥16 unique chars, no duplicates). Assign a named owner to the salt. Without an owner, rotation becomes an outage with no on-call playbook.
Support
There is no upstream support relationship to lean on: one maintainer, low star count, no dependents. Document the fallback before adoption — call hashids/hashids directly through the adapter, or fork the glue — so it's a known option during an incident rather than a discovery. The BSD-2 licence and tiny surface are what make that acceptable.
Scaling
CPU-negligible — pure string arithmetic, no I/O, safe on hot paths at high QPS. The scaling concern is output shape, not throughput: length is a floor that pads and grows with input magnitude, so any fixed-width assumption in a column type, validation regex, or UI layout breaks as volume climbs. Multi-connection is the scaling axis for product surface (short coupon codes vs. longer tenant IDs), not for load.
Failure modes
[] → undefined index → 500. Adapter returns ?int; form requests validate shape first.env() evaluated at runtime → silently null post-config:cache. Env lookups confined to config/hashids.php; boot assertion catches it.Ramp-up An engineer is productive in under a day; adapter + tests + first surface in one to two. The expensive knowledge isn't the API — it's the failure modes, so put "what hashids is not," "how decode fails," and "what rotation does" in a runbook rather than in tribal memory. Give the team a half-hour walkthrough of that runbook. Support needs something different: a rotation policy and a documented response for a customer whose emailed link 404s post-rotation.
Pilot exit criteria: one surface live; legacy-URL metrics flat over ~4 weeks; decode-failure rate inside threshold; salt in the secret manager with a named owner and a written rotation runbook on file.
How can I help you explore Laravel packages today?