yieldstudio/eloquent-public-id
A Laravel package that adds unique, Stripe-style public IDs to Eloquent models, helping hide auto-incrementing primary keys and create cleaner, safer URLs. Includes configurable prefixes and simple trait-based integration.
What it is. A trait for Laravel 9+ Eloquent models that attaches an opaque, externally-facing identifier (presumably UUID/ULID) to a model while leaving the auto-increment or bigint primary key intact. This is the "parallel public ID" pattern: internal 8-byte integer keys continue to back joins and foreign keys, while URLs, APIs, and webhooks expose a non-enumerable token.
Architecture fit. Good, and deliberately conservative. It avoids the more invasive alternative of making ULIDs the primary key (Laravel's native HasUlids/HasUuids), which would ripple through every foreign key, pivot table, and index in an existing schema. Because it is a per-model trait, adoption can be scoped to a single domain — public-facing aggregates first — without a schema-wide decision. The pattern also directly serves two recurring product requirements: preventing resource enumeration via sequential IDs and decoupling externally-visible identity from storage implementation.
Integration feasibility. High, but only because the surface area is tiny. Realistically this is 30–60 lines: a booted()/creating hook, a generator call, and a published migration stub. If any behavior is missing or unsuitable — column type, generator choice, backfill handling, route binding — replacing it with an in-house trait is an afternoon of work, not a migration project. That materially caps the downside of the package itself.
Technical risk. The risk is supply-chain and longevity, not engineering. The package has 0 stars, 0 dependents, one release on 2025-04-09, and a "Laravel 9 and above" support claim that is unevidenced for 10/11/12 or PHP 8.3/8.4. Laravel 9 itself is past security support, so the stated floor tells us nothing about current-version testing. Mitigating factors: MIT license permits vendoring/forking, the attack surface is a single trait with no dependencies of note, and there is no runtime service or network surface. The residual risks are (a) schema opinions baked into the migration stub that don't match our conventions, (b) silent breakage on a future Laravel major, and (c) an abandoned upstream while we are mid-rollout.
Key questions to answer before adoption:
char(36), char(26), binary(16))? Can we override it without forking?insert()/upsert paths.)getRouteKeyName(), or must we? Does it provide a marker interface for type-hinting and controller signatures?exists() check that races?SoftDeletes: does a unique index on public_id collide when rows are recreated?Stack fit. Laravel 9+, any Eloquent model, no infrastructure requirements. Verify against our actual target (likely Laravel 11 or 12) with a throwaway composer require and a two-model spike before committing.
Decision gate first. The real architectural choice is "parallel public ID + bigint PK" versus "ULID primary key." For a mature schema with many foreign keys, this package's approach is correct and far cheaper. For greenfield services, native ULIDs are simpler and dependency-free. Pick one convention org-wide and do not mix — mixed identity strategies are the single most common source of "which ID is this?" bugs in API surface reviews.
Migration path.
pt-online-schema-change or online DDL for large tables) → add unique index → optionally enforce NOT NULL.insert()/upsert() paths get an explicit generator call (Eloquent events do not fire for those).getRouteKeyName(), API resources, route model binding, and any signed URLs or webhook payloads.Compatibility and sequencing. Watch for: bulk inserts bypassing the trait, firstOrCreate/updateOrCreate paths, queued jobs and cached payloads holding old IDs across a deploy, mobile clients caching internal IDs, and soft-deleted rows interacting with the unique index. Sequence by domain, not by table: pilot on one low-traffic public model, measure index size and query plans, then propagate to accounts/billing/public APIs, then freeze internal IDs from external responses, then deprecate the old URL shapes behind a redirect window.
Contingency. Given 0 dependents, plan for abandonment from day one. If the spike passes and we adopt beyond the pilot, fork the trait into an internal namespace or vendor it under app/Support, keep the MIT attribution, and treat upstream as a reference implementation rather than a dependency.
Maintenance. Treat this as owned code. With one release and no community, nobody else will catch a Laravel 12 or PHP 8.5 incompatibility; we will. Budget roughly a day per major framework upgrade to re-validate, and let Renovate/Dependabot surface constraint conflicts early. The code volume is small enough that a fork-and-patch is measured in hours, which keeps this from becoming a genuine platform liability.
Support. None — no SLA, no security policy, no maintainer responsiveness guarantee (the publisher is an agency, and the package reads as an extraction from internal work). Compensate with a mandatory code review at adoption, an SBOM entry, and an ADR recording the decision, the pinned version, and the fork trigger.
Scaling. The public ID column adds ~26–36 bytes per row plus a secondary unique index — negligible below a few million rows, measurable above ~50M. Generation must be application-side so read replicas stay consistent. A ULID or UUIDv7 generator gives roughly time-ordered inserts and avoids the random-insert page-split behavior that UUIDv4 causes in InnoDB; UUIDv4 on a high-write table is the one scaling pattern worth actively rejecting. Route model binding on public_id must be backed by a unique index or every lookup becomes a full scan — this is the most likely production incident.
Failure modes.
NULL public IDs and 500s on lookup. Add a test that asserts every externally-routed model has a non-null public ID.Ramp-up. Hours, not days. Any Laravel developer can read the trait in ten minutes; the real onboarding task is the convention — "public IDs at the boundary, integer IDs internally, never both." Document it in the ADR and onboarding notes, and cover it with two Pest tests: uniqueness under concurrent creation, and route binding resolving by public ID while rejecting the integer ID.
How can I help you explore Laravel packages today?