refactor(schema): migrate eleven pivot/EAV tables to ULID per addendum Q1

Retires the "integer AI PK for join performance" exception documented
in earlier migrations and SCHEMA.md §3.5.11 Rule 1. Every business and
pivot table now uses ULID primary keys, per
/dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md Q1.

Tables migrated (WS-1 A-01 through A-11):
- Pure pivots: organisation_user, event_user_roles, crowd_list_persons,
  event_person_activations
- Model-backed: user_organisation_tags, person_section_preferences,
  mfa_backup_codes, mfa_email_codes, form_submission_section_statuses,
  form_values, form_value_options

Migration pattern: one new migration per table (plus one combined for
the form_values / form_value_options FK pair), timestamped today,
dropping + recreating with the new ULID PK. Pre-launch — no backfill
required. Original migrations remain in place; the new migrations
apply in timestamp order for a clean schema history.

Pivot model correction (addendum drift):
The addendum's "no model required for pure pivots" reading did not
account for Laravel's BelongsToMany::attach() — it cannot auto-generate
a pivot ULID without a Pivot subclass. Minimal Pivot classes under
app/Models/Pivots/ (OrganisationUser, EventUserRole, CrowdListPerson,
EventPersonActivation) carry HasUlids so attach() works. The six
belongsToMany relations (User.organisations / .events, Organisation.users,
Event.users, CrowdList.persons, Person.crowdLists) now ->using(...) the
appropriate Pivot class. DB::table()->insert() on event_person_activations
in DevSeeder populates the ULID inline via Str::ulid(). FormValueObserver
uses bulk FormValueOption::insert() which bypasses model events — ULIDs
are now generated inline there too.

Docs:
- SCHEMA.md §3.5.11 Rule 1 rewritten to mandate ULID on pivots too, with
  legacy note citing the addendum.
- All eleven table entries updated from "int AI PK" to "ULID PK" with
  addendum Q1 references.
- form_values and form_submission_section_statuses prose blocks updated
  to drop the retired ARCH §4.4 / "high-volume pivot" rationale.
- form_value_options.form_value_id column type corrected from
  "int FK" to "ULID FK".

Tests: tests/Feature/Schema/UlidPrimaryKeyTest.php covers HasUlids trait
presence, ULID shape + 26-char Crockford pattern, Route::bind resolution,
distinct + sortable pivot ULIDs, attach() auto-generation on pure pivots,
and the A-10/A-11 FK chain. 10 tests / 28 new assertions. Full suite:
977 passed (2662 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 16:38:08 +02:00
parent 0041b3defa
commit a92ddc48ec
30 changed files with 739 additions and 44 deletions

View File

@@ -62,6 +62,7 @@ final class CrowdList extends Model
public function persons(): BelongsToMany
{
return $this->belongsToMany(Person::class, 'crowd_list_persons')
->using(\App\Models\Pivots\CrowdListPerson::class)
->withPivot('added_at', 'added_by_user_id');
}
}

View File

@@ -101,6 +101,7 @@ final class Event extends Model
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'event_user_roles')
->using(\App\Models\Pivots\EventUserRole::class)
->withPivot('role')
->withTimestamps();
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models\FormBuilder;
use App\Models\User;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class FormSubmissionSectionStatus extends Model
{
use HasFactory;
use HasUlids;
protected $fillable = [
'form_submission_id',

View File

@@ -4,18 +4,20 @@ declare(strict_types=1);
namespace App\Models\FormBuilder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* EAV storage row. Int PK for fast joins (ARCH §4.4). Typed columns are
* populated by FormValueObserver based on field.value_storage_hint.
* EAV storage row. Typed columns are populated by FormValueObserver based
* on field.value_storage_hint.
*/
final class FormValue extends Model
{
use HasFactory;
use HasUlids;
protected $fillable = [
'form_submission_id',

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models\FormBuilder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -16,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class FormValueOption extends Model
{
use HasFactory;
use HasUlids;
public $timestamps = false;

View File

@@ -5,11 +5,14 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class MfaBackupCode extends Model
{
use HasUlids;
protected $fillable = [
'user_id',
'code_hash',

View File

@@ -5,11 +5,14 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class MfaEmailCode extends Model
{
use HasUlids;
protected $fillable = [
'user_id',
'code',

View File

@@ -56,6 +56,7 @@ final class Organisation extends Model
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'organisation_user')
->using(\App\Models\Pivots\OrganisationUser::class)
->withPivot('role')
->withTimestamps();
}

View File

@@ -92,6 +92,7 @@ final class Person extends Model
public function crowdLists(): BelongsToMany
{
return $this->belongsToMany(CrowdList::class, 'crowd_list_persons')
->using(\App\Models\Pivots\CrowdListPerson::class)
->withPivot('added_at', 'added_by_user_id');
}

View File

@@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class PersonSectionPreference extends Model
{
use HasUlids;
public $timestamps = false;
protected $fillable = [

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Models\Pivots;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Relations\Pivot;
final class CrowdListPerson extends Pivot
{
use HasUlids;
protected $table = 'crowd_list_persons';
public $incrementing = false;
protected $keyType = 'string';
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Models\Pivots;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Relations\Pivot;
final class EventPersonActivation extends Pivot
{
use HasUlids;
protected $table = 'event_person_activations';
public $incrementing = false;
protected $keyType = 'string';
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Models\Pivots;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Relations\Pivot;
final class EventUserRole extends Pivot
{
use HasUlids;
protected $table = 'event_user_roles';
public $incrementing = false;
protected $keyType = 'string';
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Models\Pivots;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Relations\Pivot;
final class OrganisationUser extends Pivot
{
use HasUlids;
protected $table = 'organisation_user';
public $incrementing = false;
protected $keyType = 'string';
}

View File

@@ -72,6 +72,7 @@ final class User extends Authenticatable
public function organisations(): BelongsToMany
{
return $this->belongsToMany(Organisation::class, 'organisation_user')
->using(\App\Models\Pivots\OrganisationUser::class)
->withPivot('role')
->withTimestamps();
}
@@ -79,6 +80,7 @@ final class User extends Authenticatable
public function events(): BelongsToMany
{
return $this->belongsToMany(Event::class, 'event_user_roles')
->using(\App\Models\Pivots\EventUserRole::class)
->withPivot('role')
->withTimestamps();
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class UserOrganisationTag extends Model
{
use HasFactory;
use HasUlids;
public $timestamps = false;

View File

@@ -81,7 +81,10 @@ final class FormValueObserver
return;
}
// Bulk insert bypasses Eloquent model events, so HasUlids does not
// populate the primary key. Generate ULIDs inline.
$rows = array_map(fn (string $opt): array => [
'id' => (string) Str::ulid(),
'form_value_id' => $value->id,
'form_field_id' => $value->form_field_id,
'form_submission_id' => $value->form_submission_id,