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

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes\Exceptions;
use RuntimeException;
final class PurposeNotFoundException extends RuntimeException
{
public static function forSlug(string $slug): self
{
return new self("Purpose '{$slug}' is not registered in config('form_builder.purposes').");
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes;
use App\Enums\FormBuilder\FormSubmissionMode;
final readonly class PurposeDefinition
{
/**
* @param list<string> $requiredBindings Binding paths in `{entity}.{attribute}` form
* (Pattern A notation used in `form_fields.binding` JSON).
* Consumed by the pre-publish check (FormSchemaService::publish)
* and, after WS-5/WS-6, by `form_field_bindings`.
*/
public function __construct(
public string $slug,
public string $label,
public string $subjectType,
public FormSubmissionMode $defaultSubmissionMode,
public bool $allowsPublicAccess,
public array $requiredBindings,
) {}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Purposes;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\FormBuilder\Purposes\Exceptions\PurposeNotFoundException;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
final class PurposeRegistry
{
/** @var array<string, PurposeDefinition>|null */
private ?array $cache = null;
public function __construct(private readonly ConfigRepository $config) {}
/** @return array<string, PurposeDefinition> keyed by slug */
public function all(): array
{
if ($this->cache !== null) {
return $this->cache;
}
/** @var array<string, array<string, mixed>> $raw */
$raw = (array) $this->config->get('form_builder.purposes', []);
$definitions = [];
foreach ($raw as $slug => $attrs) {
$mode = $attrs['default_submission_mode'] ?? null;
if (! $mode instanceof FormSubmissionMode) {
throw new \InvalidArgumentException(
"Purpose '{$slug}' has invalid default_submission_mode; expected FormSubmissionMode enum case."
);
}
$definitions[(string) $slug] = new PurposeDefinition(
slug: (string) $slug,
label: (string) ($attrs['label'] ?? ''),
subjectType: (string) ($attrs['subject_type'] ?? ''),
defaultSubmissionMode: $mode,
allowsPublicAccess: (bool) ($attrs['allows_public_access'] ?? false),
requiredBindings: array_values((array) ($attrs['required_bindings'] ?? [])),
);
}
return $this->cache = $definitions;
}
public function get(string $slug): PurposeDefinition
{
$all = $this->all();
if (! isset($all[$slug])) {
throw PurposeNotFoundException::forSlug($slug);
}
return $all[$slug];
}
public function has(string $slug): bool
{
return isset($this->all()[$slug]);
}
/** @return list<string> unique sorted list of subject_type aliases */
public function allSubjectTypes(): array
{
$types = array_values(array_unique(array_map(
static fn (PurposeDefinition $p): string => $p->subjectType,
$this->all(),
)));
sort($types);
return $types;
}
/** @return list<string> slugs with allows_public_access === true */
public function publicAccessibleSlugs(): array
{
return array_values(array_keys(array_filter(
$this->all(),
static fn (PurposeDefinition $p): bool => $p->allowsPublicAccess,
)));
}
}

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,
));
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Enums\FormBuilder\FormSubmissionMode;
/*
|--------------------------------------------------------------------------
| Form Builder Purpose registry (v1.0)
|--------------------------------------------------------------------------
|
| Declarative set of purposes served by the universal form builder. v1.0
| ships seven purposes. Adding a new purpose requires a code change (this
| file) plus, typically, a new listener per ARCH-CONSOLIDATION §3
| besluit 4. Purposes are not user-defined.
|
| Each purpose declares:
| - label (NL): organizer-facing label
| - subject_type: morph alias used for form_submissions.subject_type
| - default_submission_mode: FormSubmissionMode enum case
| - allows_public_access: schema-level public submission without token
| - required_bindings: list of "{entity}.{attribute}" binding paths
| that must be present on a schema before publish
|
*/
return [
'event_registration' => [
'label' => 'Aanmelding vrijwilligers/crew',
'subject_type' => 'person',
'default_submission_mode' => FormSubmissionMode::SINGLE,
'allows_public_access' => true,
'required_bindings' => ['person.email', 'person.first_name', 'person.last_name'],
],
'artist_advance' => [
'label' => 'Artiest advance',
'subject_type' => 'artist',
'default_submission_mode' => FormSubmissionMode::DRAFT_SINGLE,
'allows_public_access' => false,
'required_bindings' => [],
],
'supplier_intake' => [
'label' => 'Leverancier intake',
'subject_type' => 'company',
'default_submission_mode' => FormSubmissionMode::SINGLE,
'allows_public_access' => false,
'required_bindings' => ['company.name'],
],
'post_event_evaluation' => [
'label' => 'Evaluatie na afloop',
'subject_type' => 'person',
'default_submission_mode' => FormSubmissionMode::SINGLE,
'allows_public_access' => false,
'required_bindings' => [],
],
'incident_report' => [
'label' => 'Incident-melding',
'subject_type' => 'person',
'default_submission_mode' => FormSubmissionMode::MULTIPLE,
'allows_public_access' => false,
'required_bindings' => [],
],
'signature_contract' => [
'label' => 'Contract-ondertekening',
'subject_type' => 'user',
'default_submission_mode' => FormSubmissionMode::SINGLE,
'allows_public_access' => false,
'required_bindings' => [],
],
'user_profile' => [
'label' => 'Profiel-update',
'subject_type' => 'user',
'default_submission_mode' => FormSubmissionMode::SINGLE,
'allows_public_access' => false,
'required_bindings' => [],
],
];