public_id (ULID) external addressing for addressable models (0046)#2539
Merged
Conversation
Add a stable, non-sequential public_id (ULID) to the externally-addressable
models, generated on create, unique per table, the integer id stays the
internal primary key.
- Models\Concerns\HasPublicId: mints a ULID on creating (non-clobbering),
clears it on replicating so replicas get a fresh id, exposes a
wherePublicId() scope. No route-binding override.
- ulid('public_id')->unique() column on the eight included baseline tables
(products, product_variants, orders, customers, collections, brands,
carts, discounts); trait + @Property mixed into each model; factories
generate one.
- Search: 'public_id' added to the five searchable Tier 1 indexers; Scout
key stays id.
- Filament: read-only copyable entry on each form + the order summary
infolist, and a toggleable copyable column on resources with a table;
shared components.public_id.label across 16 locales in both packages.
- Upgrade: one migration over all eight tables — add nullable, batched
backfill seeded from created_at, then tighten to NOT NULL + unique;
guarded/idempotent, no down().
- Fix: DuplicateProduct collided on the copied public_id; resolved
generically via the trait's replicating hook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The components.public_id.label key shipped as an English placeholder in every locale. Provide an actual translation for each of the other 15 locales in both the filament and admin packages; en stays the source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
public_id is a machine address for APIs, webhooks and syncs; customers never quote it and operators never act on it. The only reader is a developer, who reads it from the database. Surfacing it in the admin UI added a translation key per resource and a column per table for no operator value. Revert the read-only copyable form entry, the toggleable table column, the order-summary infolist entry, and the components.public_id.label key across all 16 locales in both the filament and admin packages. The core concern (trait, column, wherePublicId scope, search indexers, factories) is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Amend the membership rule from a curated inclusion list of eight tables to a default-on rule: every standalone model gets a public_id except link/pivot models and immutable-standard-code models (ISO currency, country, language, state). Record why: the package cannot know what a given store exposes over its API, and a mutable handle is not a safe external address, so guessing 'not addressable' is an opinion the package should not hold and a wrong guess forces a downstream migration on Lunar's own tables. Also record the no-admin-surfacing decision and the mechanism (trait plus an ArchitectureTest allowlist, not Models\Base, since the excluded pivots and code models share that ancestor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend public_id from the original eight tables to every standalone model, excluding only link/pivot models (no independent identity) and immutable-standard-code models (Country, Currency, Language, State — the ISO code is already a stable public id). 40 tables carry it. - HasPublicId added to 32 more models; Staff (extends Authenticatable, not Base) also gains Lunar's custom builder so the typed wherePublicId() scope resolves. - Column born on each baseline create migration; factories generate one (Asset has no factory). - HasPublicId now mints at construction (initializeHasPublicId) as well as on creating, so the id is present even when a row is persisted through an event-skipping path (createQuietly/saveQuietly/ withoutEvents). creating still re-mints an explicitly-nulled value; replicating still resets a copy to a fresh id. - ProductOptionIndexer emits public_id (now an included searchable model). - Upgrade migration backfills all 40 tables (was 8), same guarded nullable -> backfill -> tighten path. - ArchitectureTest enforces membership: every Base-subclass model uses HasPublicId except the documented exclusion allowlist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wychoong
reviewed
Jul 3, 2026
| DB::table($table) | ||
| ->whereNull('public_id') | ||
| ->orderBy('id') | ||
| ->each(function ($row) use ($table) { |
| } | ||
|
|
||
| Schema::table($table, function (Blueprint $blueprint) { | ||
| $blueprint->ulid('public_id')->nullable()->after('id'); |
Contributor
Contributor
Author
There was a problem hiding this comment.
As it's the upgrade package, is it that much of a concern?
Contributor
There was a problem hiding this comment.
store deployed with lunar 1.0 likely won't be using DB version < 2022, so maybe not a concern.
but should chunk the backfill
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Implements spec 0046. Adds a stable, non-sequential
public_id(ULID) as the external address for Lunar models — generated on create, unique per table, exposed for APIs, webhooks, and syncs. The integeridstays the internal primary key and FK target;public_idis purely the outward-facing handle.Membership — default-on, exclusion-driven
The original draft carried
public_idon a curated list of eight tables. That list wasn't derivable from a rule, so it read as arbitrary and had real gaps (Fulfilment/Transaction— which a fulfilment/payment webhook must address — were missing). The membership is now a default with a short, principled exclusion list:40 tables carry it. Two reasons drove the change: (1) the package cannot know which entities a given store exposes over its API, so guessing "not addressable" is an opinion it shouldn't hold — and a wrong guess forces a downstream migration on Lunar's own tables; (2) a mutable
handle/codeis not a safe external address (a rename breaks every stored reference), sohandleandpublic_idcoexist — readable key vs. rename-proof machine address.An
ArchitectureTestcase enforces the rule: everyBase-subclass model must useHasPublicIdunless it's on the exclusion allowlist, so a new model can't silently miss the decision.Changes
Models\Concerns\HasPublicId— mints a ULID at construction (initializeHasPublicId) so the id is present even when a row is persisted through an event-skipping path (createQuietly/saveQuietly/withoutEvents); still re-mints oncreatingwhen an explicit null is assigned, and resets to a fresh id onreplicating(a copy is a distinct record). Exposes awherePublicId()scope typed against the customBuilder. No route-binding override (idstays the route key).@propertyon 32 further models.Staff(extendsAuthenticatable, notBase) also gains Lunar's custom builder so its typed scope resolves.ulid('public_id')->unique()born on each baselinecreate_*_tablemigration; factories generate one (Asset has no factory).ProductOptionIndexernow emitspublic_id(it's an included searchable model).getScoutKey()staysid.public_idnullable → batched backfill seeded from each row'screated_at→ tighten toNOT NULLwith a unique index. Guarded/idempotent, nodown().No admin surfacing
public_idis deliberately not shown in the Filament panel. It's a machine address — customers never quote it and operators never act on it; the only reader is a developer, who reads it from the database. Surfacing it would add a translation key per resource and a column per table for no operator value. (An earlier revision added those; they were reverted.)Breaking changes
None for existing consumers — purely additive. New surface:
HasPublicId, thepublic_idattribute +wherePublicId()scope on the included models, andpublic_idin the relevant search indexers.Verification
HasPublicIdTest,AddPublicIdToAddressableModelsTest, and theArchitectureTestmembership rule.🤖 Generated with Claude Code