Skip to content

public_id (ULID) external addressing for addressable models (0046)#2539

Merged
glennjacobs merged 5 commits into
2.xfrom
feature/0046-public-id-external-addressing
Jul 1, 2026
Merged

public_id (ULID) external addressing for addressable models (0046)#2539
glennjacobs merged 5 commits into
2.xfrom
feature/0046-public-id-external-addressing

Conversation

@glennjacobs

@glennjacobs glennjacobs commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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 integer id stays the internal primary key and FK target; public_id is purely the outward-facing handle.

Membership — default-on, exclusion-driven

The original draft carried public_id on 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:

Every standalone model gets a public_id, except link/pivot models (no independent identity — addressed through the rows they join) and immutable-standard-code models (Country, Currency, Language, State — the ISO code is already a stable public id, and a better one than a ULID).

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/code is not a safe external address (a rename breaks every stored reference), so handle and public_id coexist — readable key vs. rename-proof machine address.

An ArchitectureTest case enforces the rule: every Base-subclass model must use HasPublicId unless 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 on creating when an explicit null is assigned, and resets to a fresh id on replicating (a copy is a distinct record). Exposes a wherePublicId() scope typed against the custom Builder. No route-binding override (id stays the route key).
  • Models — trait + @property on 32 further models. Staff (extends Authenticatable, not Base) also gains Lunar's custom builder so its typed scope resolves.
  • Schemaulid('public_id')->unique() born on each baseline create_*_table migration; factories generate one (Asset has no factory).
  • SearchProductOptionIndexer now emits public_id (it's an included searchable model). getScoutKey() stays id.
  • Upgrade — one migration over all 40 tables: add public_id nullable → batched backfill seeded from each row's created_at → tighten to NOT NULL with a unique index. Guarded/idempotent, no down().

No admin surfacing

public_id is 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, the public_id attribute + wherePublicId() scope on the included models, and public_id in the relevant search indexers.

Verification

  • Tests: HasPublicIdTest, AddPublicIdToAddressableModelsTest, and the ArchitectureTest membership rule.
  • All seven CI suites pass in isolation: core 906, admin 230, filament 40, shipping 98, stripe 40, search 4, upgrade 51. Pint clean, PHPStan No errors.

🤖 Generated with Claude Code

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>
@glennjacobs glennjacobs requested a review from alecritson July 1, 2026 08:30
glennjacobs and others added 3 commits July 1, 2026 14:42
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>
@glennjacobs glennjacobs merged commit f00450f into 2.x Jul 1, 2026
19 checks passed
@glennjacobs glennjacobs deleted the feature/0046-public-id-external-addressing branch July 1, 2026 15:09
@github-project-automation github-project-automation Bot moved this from Todo to Done in Roadmap Jul 1, 2026
DB::table($table)
->whereNull('public_id')
->orderBy('id')
->each(function ($row) use ($table) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add chunkById

}

Schema::table($table, function (Blueprint $blueprint) {
$blueprint->ulid('public_id')->nullable()->after('id');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe good idea to avoid using ->after ?
Image
Latest LTS is MySQL 8.4 and MariaDB 12.3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it's the upgrade package, is it that much of a concern?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store deployed with lunar 1.0 likely won't be using DB version < 2022, so maybe not a concern.

but should chunk the backfill

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants