laravel-json-api/hashids
Adds Hashids support to Laravel JSON:API, encoding resource IDs into short, obfuscated strings and decoding them on request. Configure salt, length, and alphabet while keeping numeric database keys hidden behind spec-compliant JSON:API routes and relationships.
Prerequisites. This package is only useful if laravel-json-api is already your API layer and at least one schema serves integer-keyed Eloquent models. If you're on hand-rolled controllers or mixed REST/GraphQL, the encoder governs only the JSON:API surface — inconsistent ID formats across surfaces are worse than consistent integers.
Install and configure (about 10 minutes):
composer require laravel-json-api/hashids
php artisan vendor:publish --provider="LaravelJsonApi\Hashids\HashidsServiceProvider"
Then set three things, and only these three:
HASHIDS_SALT=<32+ random bytes from your secrets manager>
HASHIDS_MIN_LENGTH=16
HASHIDS_ALPHABET=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890
The published config (typically config/hashids.php) should read those via env() only inside the config file — see the config:cache gotcha below.
Register the encoder once, at the boundary. Prefer server-level registration in app/JsonApi/V1/Server.php so every schema inherits it, rather than per-schema wiring that drifts:
public function withEncoder(): Encoder
{
return new HashidsEncoder(/* config-driven */);
}
If the package only supports per-schema wiring in your framework version, register it in a shared base schema class so there is exactly one call site. Confirm the exact class name and constructor signature against the repo source before writing this — it's ~50 lines, read it.
Where to look first, in this order:
vendor/laravel-json-api/hashids/src/ — the whole package. You should be able to read it in one sitting; that is the point.LaravelJsonApi\Contracts\Encoder\Encoder contract — this is the seam, and it's what you'll implement if you ever migrate to ULIDs or signed IDs.Server class and each schema's id() field.First use case — prove the round trip on one bounded resource. Pick a low-risk resource (e.g. Post) and assert all of these before touching anything else:
GET /api/v1/posts # IDs are strings like "jN7x9K"
GET /api/v1/posts/jN7x9K # 200, resolves the right row
GET /api/v1/posts/1 # 404, not 500
POST /api/v1/posts # response `id` is the hash, not the integer
GET /api/v1/posts?include=author # relationship identifiers and links are hashed
GET /api/v1/posts?page[after]=... # cursors are hashed and round-trip
Pagination is the one people forget: laravel-json-api cursor pagination encodes resource IDs, so your page[after] values change format the moment you register the encoder.
Keep the integer primary key; encode only at the edge. No schema migration, no UUID columns, no index changes. Your policies, scopes, joins, and queue payloads keep using integers internally. The encoder is a presentation concern and should stay one.
Document IDs as opaque strings in your contract, at the same time you ship the code. In your OpenAPI spec: type: string, example: "jN7x9K", and an explicit sentence that IDs are opaque, non-parseable, and must be treated as stable strings. Clients that intval() your IDs will break in ways you'll hear about from support, not from CI.
Make the schema id() field string-typed. If the field is declared as a numeric ID, the JSON:API document will emit the wrong type and typed clients will reject it. Assert the serialized type in a test.
JsonApiResponse::assertId('jN7x9K')->assertJsonPath('data.id', fn ($id) => is_string($id));
Pin the hash in tests; don't compute-and-compare. A test that encodes with the same code path it's testing proves nothing. Pick a known integer and assert the literal expected string:
it('encodes post 1 to a known id', function () {
$post = Post::factory()->create(['id' => 1]);
$this->jsonApi()->get("/api/v1/posts/{$post->getRouteKey()}")->assertFetchedOne($post);
expect(app(Encoder::class)->encode(...))->toBe('jN7x9K');
});
This is your canary: it fails loudly if someone rotates the salt, bumps MIN_LENGTH, changes the alphabet, or a framework upgrade alters the codec.
Add a config-cache regression test to the upgrade checklist. Boot the app with cached config in CI and re-run one encode/decode assertion. Salt drift between cached and uncached config is a production-only failure that never reproduces locally.
Vendor the source if your dependency policy allows it. Copy the encoder into app/JsonApi/Encoder/HashidsEncoder.php, drop the composer dependency, and keep one test asserting output equality with the upstream algorithm. Five stars and zero dependents means you are adopting source, not relying on a library — the migration cost back to upstream is trivial, and this removes the bus-factor conversation entirely.
Resolve public IDs outside the framework by injecting the encoder, not by calling Hashids::decode() with a duplicated salt. Any import script, webhook handler, or analytics job that must map a public ID to a record goes through the same registered encoder instance.
Plan the escape hatch now. Implement the same Encoder contract if you later move to UUIDv7 or signed identifiers. Because the framework talks to a contract, the swap is one class and one registration line — provided nothing outside the codec has learned to parse IDs.
Non-JSON:API routes don't get this for free. Custom routes with {post} binding receive the raw integer. Either scope them out deliberately and document it, or add explicit hash resolution. Do not half-cover the surface.
Hashids is reversible — the salt is the entire secret. Anyone with the salt decodes every ID. Never describe this as a security control in a planning doc or a pen-test scope, because authorization reviews and incident response will be calibrated wrongly. Authorization stays in policies; opacity is anti-enumeration only.
Never put PII or anything sensitive inside an ID. It's an integer with a fancy coat.
Salt rotation is a breaking API change, not a config change. Every cached URL, bookmark, shared link, webhook payload, export, queue message, and analytics row keyed on the old format dies at once. Treat rotation as a versioned event with a client migration and a dual-read window — decode the new format, fall back to the old, and never dual-emit.
config:cache is the classic production-only failure. If the salt is ever read via env() outside a config file (in a service provider closure, an encoder constructor, anywhere), cached and uncached boots disagree. Prod returns 404s for valid URLs while local tests pass. Read the salt exclusively through config('hashids.salt').
Changing MIN_LENGTH or ALPHABET is the same one-way door as rotating the salt. Min-length changes pad differently; alphabet changes the output character set. Decide once, write it in the API contract, and let the pinned-hash test enforce it.
Invalid input must be a 404, not a 500 or a 0 lookup. A garbage hash string decodes to null/empty. Make sure that path short-circuits to Not Found rather than surfacing a TypeError or quietly querying where id = 0.
Null IDs on unsaved models. Encoding should pass null through as null; guard resource construction for models without a primary key so you don't emit "" or throw.
Don't assume a fixed ID length. Hashids output grows with the integer, so min_length is a floor, not a size. A size:12 validation rule or a VARCHAR(12) column will bite you once IDs get large.
Pagination cursors change format. Existing clients holding page[after] values in the old numeric format will break. Either version the endpoint or accept a one-time cursor invalidation at deploy, announced in advance.
Test filtering and sorting by ID explicitly. Whether your filter receives the decoded integer or the raw hash depends on how the filter is wired. Verify it; if it's raw, decode in the filter or document that clients must not filter by ID.
Centralize encoding across servers and services. Two JSON:API servers (v1 and v2) with different salts will silently produce different public IDs for the same record, and nothing will fail until a client cross-references them. One encoder, one salt, one registration point.
Log both identifiers. Emit the public ID in responses and the internal PK in server logs (and ideally both in a correlation field). Otherwise the first support ticket about a "broken link" becomes an engineering incident because nobody can map the string back to a row.
Brief support and customer success. They need to know: public IDs are strings, not numbers; they cannot be incremented, parsed, or guessed; a "wrong-looking" ID is probably a stale cached link. Without that, the first ticket escalates as a bug.
Extension point to remember: the framework's Encoder contract is where you swap this out later. Keep the codec the only place that knows what an ID looks like, and the eventual move to ULIDs or signed IDs stays a one-file change instead of an API-wide migration.
How can I help you explore Laravel packages today?