D25 — Internal bigint PK + external UUIDv7 on every domain entity (supersedes the UUID-PK rule)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
User decision — replaces the earlier "tenant-scoped models use a UUID primary key" convention
(the old TenantIsolationTest rule + the tenant model's UUIDGenerator). Every domain entity now has:
- an auto-incrementing
bigintprimary key — used for fast, compact foreign keys + relations INTERNALLY and never exposed; and - a unique
uuid(UUIDv7) — the ONLY identifier exposed externally (URLs, APIid, DTOs).
This preserves the opacity the old rule wanted (no enumerable / count-leaking ids leave the app)
while gaining bigint join/index performance internally. Chosen interactively: uniform (incl.
tenants), uuid on all domain entity tables (pure pivots / unexposed reference tables excluded),
format UUIDv7.
Why UUIDv7 (verified against the stack): Postgres 18.4 has native uuidv7() +
gen_random_uuid() and the native 16-byte uuid type; Laravel has Str::uuid7(). UUIDv7 is
time-ordered, so the unique uuid index stays locality-friendly under write load. ULID was rejected
(no native PG type — $table->ulid() → char(26)); v4 was the fallback only if zero temporal leak
were required (v7 encodes a coarse creation timestamp — an accepted, mild trade-off).
Mechanism:
- Generation is DB-side (Postgres owns it, like the bigint sequence): the
uuidcolumn carriesDEFAULT uuidv7()(PG18 native), so EVERY write path — Eloquent, raw SQL, bulk inserts, other services — gets a uuid; the invariant is enforced at the database, not the app.Modules\Foundation\Concerns\HasPublicUuidtherefore does NOT generate. It (a) setsgetRouteKeyName() = 'uuid'so route-model binding + JSON:API ids resolve by uuid, and (b) on thecreatedevent loads the DB-generated uuid back into the model — Eloquent's INSERT returns only the primary key, so without this a freshly-created model wouldn't know its uuid (one extra SELECT per Eloquent create; bulkinsert()skips it but the DB still fills the column). Applied toUser,ClientUser,TenantRole,Tenant. - Migration convention:
$table->id(); $table->uuid('uuid')->unique()->default(new Expression('uuidv7()'));on every entity table; internal FKs useforeignId(...)->constrained(...). PostgreSQL has no unsigned integer types —id()=bigserialandforeignId()both emit plainbigint(unsignedBigIntegerwould be identical on PG), so we usebigintthroughout; signed range (~9.2e18) is far beyond any id count. - Tenancy:
tenancy.models.id_generator => null→ thetenantstable auto-increments a bigint PK; stancl'sGeneratesIdskeys RLS off that bigint (getTenantKey()), so the RLS session varstables.current_tenant+ everytenant_idFK are bigint. The SPA sends the tenant's uuid inX-Tenant; identification resolves it via theRequestDataTenantResolvertenant_model_column => 'uuid'(no custom resolver needed).uuidis added to the Tenant's VirtualColumngetCustomColumns()so it stays a real column, not virtualized intodata. (Confirmed in passing: internachi/modular auto-loads module migrations — the Authorization tables migrate without an explicitloadMigrationsFrom.) - External exposure:
ClientUserData.id+TenantSummaryData.id= the uuid;tenant_idis surfaced as the tenant's uuid (a client is always in its tenant's context); thewhoamiprobe returns the tenant uuid. The bigint never appears in any API/URL.
Codified + enforced. $table->identifiers() — a Blueprint macro registered in
FoundationServiceProvider — adds an entity's two identifier columns: the bigint PK + uuid
(DEFAULT uuidv7(), unique), in one call; use it for every new entity table. The convention is mandatory-unless-excluded via two guards:
tests/Architecture/ModelConventionsTest— every model in a module\Modelsnamespace mustuse HasPublicUuid, unless added to its exclusion list (currently just theTenantRolePermissionpivot). A new model that forgets the trait fails until the trait is added or it is consciously excluded.tests/Feature/SchemaConventionTest— introspects the REALIZED schema aftermigrate:fresh: everypublictable must have abigint id+ auuidcolumn defaulting touuidv7(), unless on its EXCLUDED list (framework/vendor tables + pure pivots). Catches a non-compliant table however it was created and verifies the columns + DB default actually exist.
CommandExecuted/QueryExecuted widened tenantId to int|string|null. RLS re-verified —
tenants:verify-isolation green on all four tenant tables, and the RLS canary + per-model isolation
suites pass against bigint keys. Backoffice spatie roles/permissions keep their stock bigint keys
(central, never exposed — no uuid); pure pivots have no uuid. Full suite + PHPStan L6 + Pint +
scramble:export all green.