feat(form-builder): introduce purpose registry and definition value object

Lands the v1.0 purpose registry (WS-2 of the consolidation sprint) as a
first-class concept: a `PurposeDefinition` value object, a
`PurposeRegistry` service keyed by slug, and a declarative
`config/form_builder/purposes.php` registry with exactly the seven
purposes from ARCH-CONSOLIDATION §6.4.

Also rebuilds the morph-map in `AppServiceProvider::boot` into three
labelled blocks: (1) domain subject types derived from
`PurposeRegistry::allSubjectTypes()`, (2) non-purpose domain types
hardcoded with comments (form_schemas owner_types, activity-log
subjects), (3) framework types (spatie/activitylog; Sanctum stays
absent per addendum Q4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 14:35:18 +02:00
parent ad941cc944
commit e93207765b
5 changed files with 341 additions and 69 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Providers;
use App\FormBuilder\Purposes\PurposeRegistry;
use App\Models\Company;
use App\Models\CrowdList;
use App\Models\CrowdType;
@@ -11,6 +12,18 @@ use App\Models\EmailChangeRequest;
use App\Models\EmailLog;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSchemaWebhook;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionDelegation;
use App\Models\FormBuilder\FormSubmissionSectionStatus;
use App\Models\FormBuilder\FormTemplate;
use App\Models\FormBuilder\FormValue;
use App\Models\FormBuilder\FormValueOption;
use App\Models\FormBuilder\FormWebhookDelivery;
use App\Models\ImpersonationSession;
use App\Models\Location;
use App\Models\MfaBackupCode;
@@ -31,18 +44,6 @@ use App\Models\User;
use App\Models\UserInvitation;
use App\Models\UserOrganisationTag;
use App\Models\UserProfile;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\FormBuilder\FormSchemaWebhook;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionDelegation;
use App\Models\FormBuilder\FormSubmissionSectionStatus;
use App\Models\FormBuilder\FormTemplate;
use App\Models\FormBuilder\FormValue;
use App\Models\FormBuilder\FormValueOption;
use App\Models\FormBuilder\FormWebhookDelivery;
use App\Models\VolunteerAvailability;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit;
@@ -57,69 +58,35 @@ use Spatie\Activitylog\Models\Activity;
class AppServiceProvider extends ServiceProvider
{
/**
* FQCN lookup for every `subject_type` alias that PurposeRegistry may
* return. Single source of truth for domain-subject model mapping,
* consumed by the morph-map build in boot(). The `artist` class is
* declared as a string because the Artist model is not yet landed;
* it is safe to register the morph alias (lazily resolved).
*
* @var array<string, class-string|string>
*/
private const PURPOSE_SUBJECT_FQCN = [
'person' => Person::class,
'user' => User::class,
'company' => Company::class,
'artist' => 'App\\Models\\Artist',
];
public function register(): void
{
//
$this->mergeConfigFrom(
base_path('config/form_builder/purposes.php'),
'form_builder.purposes',
);
$this->app->singleton(PurposeRegistry::class);
}
public function boot(): void
{
// Morph map: explicit keys for every class that can end up in a
// polymorphic column. Before S1 there were no morphTo/morphMany
// relations, but spatie/activitylog stores subject/causer as morph
// columns — so every model passed to performedOn()/causedBy() MUST
// be registered. Keep form-builder subject_types in sync with
// config/form_subjects.php.
Relation::enforceMorphMap([
// Form-builder subject types
'event' => Event::class,
'user' => User::class,
'user_profile' => UserProfile::class,
'person' => Person::class,
'company' => Company::class,
'organisation' => Organisation::class,
// 'artist' added when artist module lands
// Additional models used as activity-log subjects/causers
'crowd_list' => CrowdList::class,
'crowd_type' => CrowdType::class,
'email_change_request' => EmailChangeRequest::class,
'email_log' => EmailLog::class,
'festival_section' => FestivalSection::class,
'impersonation_session' => ImpersonationSession::class,
'location' => Location::class,
'mfa_backup_code' => MfaBackupCode::class,
'mfa_email_code' => MfaEmailCode::class,
'organisation_email_settings' => OrganisationEmailSettings::class,
'organisation_email_template' => OrganisationEmailTemplate::class,
'person_identity_match' => PersonIdentityMatch::class,
'person_section_preference' => PersonSectionPreference::class,
'person_tag' => PersonTag::class,
'shift' => Shift::class,
'shift_assignment' => ShiftAssignment::class,
'shift_waitlist' => ShiftWaitlist::class,
'time_slot' => TimeSlot::class,
'trusted_device' => TrustedDevice::class,
'user_invitation' => UserInvitation::class,
'user_organisation_tag' => UserOrganisationTag::class,
'volunteer_availability' => VolunteerAvailability::class,
// Form-builder models — used as activity-log subjects via the
// logSchemaChange / logFieldChange helpers, and (in S2+) as
// polymorphic webhook payload subjects.
'form_schema' => FormSchema::class,
'form_schema_section' => FormSchemaSection::class,
'form_field' => FormField::class,
'form_field_library' => FormFieldLibrary::class,
'form_submission' => FormSubmission::class,
'form_submission_section_status' => FormSubmissionSectionStatus::class,
'form_submission_delegation' => FormSubmissionDelegation::class,
'form_value' => FormValue::class,
'form_value_option' => FormValueOption::class,
'form_template' => FormTemplate::class,
'form_schema_webhook' => FormSchemaWebhook::class,
'form_webhook_delivery' => FormWebhookDelivery::class,
]);
$this->registerMorphMap();
Person::observe(PersonObserver::class);
User::observe(UserObserver::class);
@@ -160,4 +127,99 @@ class AppServiceProvider extends ServiceProvider
}
});
}
/**
* Morph-map is built from three labelled blocks:
*
* 1. Domain subject types derived from PurposeRegistry. These are
* the aliases allowed on `form_submissions.subject_type`.
* 2. Non-purpose domain types form_schemas owner_types that are
* not subjects (`organisation`, `event`, `user_profile`) plus
* domain models referenced as activity-log subjects/causers.
* 3. Framework types spatie/activitylog subjects.
*
* Sanctum's `personal_access_tokens.tokenable_type` is intentionally
* absent (addendum Q4: framework polymorphie uses framework defaults,
* not the domain morph-map). `MorphMapAlignmentTest` guards blocks
* 1 and 2 against drift.
*/
private function registerMorphMap(): void
{
/** @var PurposeRegistry $registry */
$registry = $this->app->make(PurposeRegistry::class);
// Block 1 — domain subject types, derived from PurposeRegistry.
$domainSubjectTypes = [];
foreach ($registry->allSubjectTypes() as $alias) {
$fqcn = self::PURPOSE_SUBJECT_FQCN[$alias] ?? null;
if ($fqcn === null) {
throw new \RuntimeException(
"No FQCN mapping for purpose subject_type '{$alias}'. "
.'Add it to AppServiceProvider::PURPOSE_SUBJECT_FQCN.'
);
}
$domainSubjectTypes[$alias] = $fqcn;
}
// Block 2 — non-purpose domain types.
$nonPurposeDomainTypes = [
// form_schemas owner_type candidates that are not subject_types.
'organisation' => Organisation::class,
'event' => Event::class,
'user_profile' => UserProfile::class,
// Domain models referenced as activity-log subjects/causers.
'crowd_list' => CrowdList::class,
'crowd_type' => CrowdType::class,
'email_change_request' => EmailChangeRequest::class,
'email_log' => EmailLog::class,
'festival_section' => FestivalSection::class,
'impersonation_session' => ImpersonationSession::class,
'location' => Location::class,
'mfa_backup_code' => MfaBackupCode::class,
'mfa_email_code' => MfaEmailCode::class,
'organisation_email_settings' => OrganisationEmailSettings::class,
'organisation_email_template' => OrganisationEmailTemplate::class,
'person_identity_match' => PersonIdentityMatch::class,
'person_section_preference' => PersonSectionPreference::class,
'person_tag' => PersonTag::class,
'shift' => Shift::class,
'shift_assignment' => ShiftAssignment::class,
'shift_waitlist' => ShiftWaitlist::class,
'time_slot' => TimeSlot::class,
'trusted_device' => TrustedDevice::class,
'user_invitation' => UserInvitation::class,
'user_organisation_tag' => UserOrganisationTag::class,
'volunteer_availability' => VolunteerAvailability::class,
// Form-builder models used as activity-log subjects and (S2+)
// polymorphic webhook payload subjects.
'form_schema' => FormSchema::class,
'form_schema_section' => FormSchemaSection::class,
'form_field' => FormField::class,
'form_field_library' => FormFieldLibrary::class,
'form_submission' => FormSubmission::class,
'form_submission_section_status' => FormSubmissionSectionStatus::class,
'form_submission_delegation' => FormSubmissionDelegation::class,
'form_value' => FormValue::class,
'form_value_option' => FormValueOption::class,
'form_template' => FormTemplate::class,
'form_schema_webhook' => FormSchemaWebhook::class,
'form_webhook_delivery' => FormWebhookDelivery::class,
];
// Block 3 — framework types (spatie/activitylog). Sanctum is absent
// on purpose; see class-level docblock.
$frameworkTypes = [
// activitylog causer/subject fall-back entries are not needed
// here because every framework morph value lands on a registered
// domain alias above. Reserved for future framework extensions.
];
Relation::enforceMorphMap(array_merge(
$domainSubjectTypes,
$nonPurposeDomainTypes,
$frameworkTypes,
));
}
}