Files
crewli/dev-docs/ARCH-FORM-BUILDER.md
bert.hausmans ad6bf3b44d docs(form-builder): align artist_advance with engagement-scoped sections
§3.2.5: clarify that advance_sections are engagement-scoped (not
artist-scoped). One master artist with two engagements advances each
trajectory independently. Drop the prose section enumeration that
predated the AdvanceSectionType enum and conflated section names
with section types — section type is the enum, name is a free string,
default seeds land in Session 3 with ArtistAdvanceDefault.

§17.3: footnote on the artist_advance row documenting engagement
context resolution — ArtistResolver::fromPortalToken looks up
artist_engagements.portal_token, returns the master Artist as subject,
populates form_submissions.event_id from the engagement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:48:38 +02:00

160 KiB
Raw Permalink Blame History

ARCH — Universal Form Builder (v1.9)

Source of truth for Crewli's universal Form Builder architecture. Any discrepancy with SCHEMA.md is resolved in favour of this document during the refactor. SCHEMA.md is updated at the end of the refactor.

Status: Approved — WS-5a landed (relational form_field_bindings); WS-5b landed in full (relational form_field_validation_rules and parallel form_field_configs; pre-WS-5b validation_rules JSON columns dropped); WS-5c landed (relational form_field_conditional_logic_groups + form_field_conditional_logic_conditions; pre-WS-5c conditional_logic JSON column dropped; no library mirror per addendum Q3); WS-5d landed (relational form_field_options; pre-WS-5d options JSON columns dropped on both form_fields and form_field_library; per-option translations live on the option row itself). WS-5 family complete. Version: 1.9 (FormFieldChildTableMorphScope abstract base class extracted across the four WS-5 siblings; concrete scope classes are now marker subclasses preserving identity for existing withoutGlobalScope(SubclassName::class) call sites; deferral notes in §6.7 / §17.4.2 / §17.5.3 / §17.6.1 / §17.6.3 replaced with forward references to the base; rationale in ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §"Uitvoering — base scope-class extractie"). Previous: 1.8 (new §17.6 "Field options (relational)" for the WS-5d split; §17.4 / §17.5 sibling-catalogue prose extended to mention the fourth concrete morph-scope; existing Webhooks section renumbered from §17.6 to §17.7), 1.7 (§8 restructured into tree-structure, relational-tables, service-boundary, operator-catalogue, cycle-detection, activity-log and legacy-migration sub-sections; contract unchanged), 1.6 (new §17.5 "Field configuration (non-validation)" for the form_field_configs split; §17.4.4 updated with the non-validation-key relocation note), 1.5 (§17.4 restructured into relational sub-sections: catalogue, relational table, callback rules, legacy JSON migration). Previous versions: 1.4 (§6.3 retitled to "Binding row specification"; new §6.7 "Relational binding table"; §17.3 pre-publish check in present tense per WS-5a), 1.3 (§10.4 public submission lifecycle — draft/save/submit split with error envelope and drift detection), 1.2.1 April 2026 (§31.10 FORM-02 contract), 1.2 April 2026 (per-purpose lifecycles, integration contracts, user guidance principles, documentation coverage, in-app copy catalogue). Created: April 2026 Owner: Architecture doc; every session reads this before starting

Breaking change acceptance: Crewli currently runs only as a development environment. Full refactor with breaking changes is acceptable and preferred over incremental non-breaking changes that would leave architectural debt.


0. TL;DR — What every session must know

Ten bullets every Claude Code session reads before starting:

  1. Goal: replace the event-scoped registration_form_fields with a polymorphic, universal form builder serving 7 distinct purposes (v1.0) from event registration to incident reports to contract signatures. Purposes are registered in config/form_builder/purposes.php via PurposeRegistry; adding a new purpose requires a code change (config + typically a listener) — per ARCH-CONSOLIDATION §3 besluit 4.
  2. Four patterns: entity-bound, submission-bound, event-registration, public. Every schema matches one.
  3. Three tables are core: form_schemas (definitions), form_fields (within schemas), form_submissions + form_values (results). Auxiliary: form_value_options, form_templates, form_field_library, form_schema_webhooks, form_webhook_deliveries, form_schema_sections, form_submission_section_statuses, form_submission_delegations, user_profiles (renamed, slimmed).
  4. Three binding patterns per field: entity-owned (A), form-owned (B), mirrored (C). Controlled via form_fields.binding JSON against a server-side entity column registry.
  5. System-internal lookups bypass the form layer. Reading user_profiles.bio from a service happens directly on the table, never through form_fields. Form layer is for UI rendering and editing only.
  6. Filtering is first-class. form_fields.is_filterable + indexed storage (value_indexed, form_value_options pivot) + filter-registry endpoint. Tag-based filters stay on existing user_organisation_tags.
  7. Governance is built-in. Retention policies, PII-flagging, consent versioning, right-to-be-forgotten workflow with anonymisation.
  8. Schema lifecycle has three mechanisms: schema_version (always tracks edits), schema_snapshot per submission (for audit trails), freeze_on_submit (prevents edits after first submission). See §18 for interaction rules.
  9. Events are the backbone. Every submission state change fires a Laravel event. Webhooks, activity log, analytics, and integrations are all listeners on these events — never ad-hoc in services.
  10. Pre-flight audit is mandatory. Every session starts with the audit block (§21) that verifies no accidental user_profile / volunteer_profile changes slipped into the codebase before work begins.

[v1.2 addition] Three more principles:

  1. User guidance is a first-class concern. Every decision-impacting UI control has contextual help (§28). Every destructive action has a preview. No feature ships without in-app guidance + VitePress docs.
  2. Integration contracts are explicit. The form builder's interaction with identity matching, crowd lists, shifts, and email notifications is contract-defined (§31). No ad-hoc cross-module coupling.
  3. Per-purpose lifecycles are documented. Each of the 7 FormPurpose values (v1.0) has a concrete lifecycle paragraph (§3.2) covering subject handling, submission flow, integrations, and sample fields. The wider vocabulary that once counted 22 variants is intentionally retired in v1.0 — purposes outside the registered seven are not part of the physical schema nor the behaviour spec.

Table of contents

  1. Rationale
  2. Four form patterns
  3. FormPurpose catalogue
    • 3.1 Enum values summary
    • 3.2 [v1.2] Per-purpose lifecycles
  4. Core tables
  5. FormFieldType catalogue
  6. Field binding
  7. Filter architecture
  8. Conditional logic
  9. Signature field details
  10. Public token flow
  11. Migration plan
  12. Seeding strategy
  13. Governance & compliance
  14. Schema lifecycle & versioning
  15. Workflows & submission reviews
  16. Internationalisation
  17. Extensibility & integrations (webhooks, custom field types, custom purposes)
  18. Consistency & interaction rules
  19. Self-hosting principle ("eat your own dog food")
  20. Trade-offs & cost awareness
  21. Pre-flight audit gate
  22. Failure-mode resilience
  23. Observability & analytics hooks
  24. Access control per field
  25. Self-hosting reservations (FormPurpose slots)
  26. Open questions / deferred decisions
  27. Out of scope
  28. [v1.2] User guidance principles
  29. [v1.2] Documentation coverage requirements per session
  30. [v1.2] In-app copy catalogue (living seed)
  31. [v1.2] Integration contracts

1. Rationale

Crewli has a proven registration form builder with EAV storage, 11 system-seeded templates, and strong adoption. It handles event registration well but is hard-coded to event_id. The same mechanism is desired for:

  • Artist advancing intake (rider, contacts, production)
  • Supplier intake (materials, power, transport)
  • Persistent user/artist/company profile fields
  • Post-event volunteer evaluations
  • Signatures for contracts, code-of-conduct, accreditation receipt
  • Incident reports during events
  • Absence reports, check-out inventory
  • Public complaints, press access requests, VIP RSVPs
  • Onboarding and setup wizards
  • Custom organisation-specific forms

The legacy volunteer_profiles table (documented in SCHEMA v1.3 but never implemented) mixed truly user-universal data (bio, photo) with event-variable data (tshirt_size, allergies) and skill-like claims (first_aid, driving_licence). Those are redistributed:

  • tshirt_size, allergies, access_requirementsform_fields per event
  • first_aid, driving_licenceperson_tags (system-seeded)
  • bio, photo_url, emergency_contact_*, reliability_score, is_ambassador → stay on a renamed, slimmed user_profiles table

The result: user_profiles becomes genuinely user-universal, the form builder handles entity-specific and event-specific variation, and the tag system handles skills and certificates.


2. Four form patterns

Every form in Crewli fits one of four patterns:

Pattern Description Subject Examples
Entity-bound Values persist on the subject. One submission per subject; updates overwrite. user, artist, company, organisation user_profile, artist_profile, company_profile
Submission-bound Each submission is a standalone document with its own lifecycle. Multiple allowed per subject. any, incl. null incident_report, feedback, post_event_evaluation
Event-registration Hybrid: one submission per person per event. Treated as entity-bound within the event's person context. person event_registration
Public No authenticated subject. Accessed via public token. null public_complaint, public_rsvp

3. FormPurpose catalogue

3.1 Enum values summary

Every form_schemas.purpose is one of these values. Purpose determines allowed subject_type, default submission_mode, and whether public access is allowed.

Purpose Subject type Mode Public? Pattern
event_registration person draft_single No Event-registration
user_profile user single No Entity-bound
artist_profile artist single No Entity-bound
company_profile company single No Entity-bound
artist_advance artist draft_single Via artist.portal_token Entity-bound + sections
supplier_intake company draft_single Via production_request.token Entity-bound + sections
incident_report user / null multiple No Submission-bound
feedback user / null multiple No Submission-bound
post_event_evaluation person single No Submission-bound
signature_contract user / artist single No Submission-bound (frozen)
signature_code_of_conduct user single No Submission-bound (frozen)
signature_receipt person multiple No Submission-bound (frozen)
absence_report person multiple No Submission-bound
check_out_inventory person multiple No Submission-bound
public_complaint null multiple Yes + captcha Public
public_press_request null multiple Yes + captcha Public
public_rsvp person / null single Yes via token Public
onboarding_wizard organisation single No Entity-bound + sections
event_setup_wizard event single No Entity-bound + sections
company_custom company single No Entity-bound
artist_custom artist single No Entity-bound
custom organisation-defined organisation-defined organisation-defined Per §17.3

3.2 [v1.2] Per-purpose lifecycles

This section defines the concrete lifecycle of each FormPurpose: who creates the schema, who submits, what integrations fire, and what the canonical system-seeded template contains. Intended as binding spec for S3 (seeding) and S5 (frontend rendering).

3.2.1 event_registration

Who creates the schema: organiser (org_admin or event_manager) per event, via form-builder UI. Seeded from EventRegistrationDefault template on first event_registration form opened.

Who submits: person (via portal, either as authenticated user or as external person during public registration).

Submission flow:

  1. Person opens the registration form (authenticated or via public URL)
  2. FormSubmission.opened_at = now; status = draft
  3. Fields rendered per conditional_logic + role_restrictions
  4. On submit: FormSubmissionSubmitted event fires
  5. Integration: PersonIdentityService invoked (see §31.1) to detect match
  6. Integration: if form contains AVAILABILITY_PICKER values, shift_assignments are provisionally created at status=claim_pending (see §31.3)
  7. Integration: if form contains SECTION_PRIORITY values, preferences are stored in person_section_preferences (existing table)
  8. Integration: confirmation email sent via CrewliMailable (see §31.4)

Canonical system template fields:

  • Naam (Pattern C → persons.first_name + last_name)
  • E-mail (Pattern C → persons.email), required
  • Telefoon (Pattern C → persons.phone)
  • Geboortedatum (Pattern C → persons.date_of_birth), PII=true
  • Shirtmaat (SELECT XS/S/M/L/XL/XXL, filterable=true)
  • Dieetwensen (CHECKBOX_LIST, filterable=true)
  • Allergieën (TEXTAREA, PII=true)
  • Toegangsbehoeften (TEXTAREA, PII=true)
  • Noodcontact naam (Pattern C → user_profiles.emergency_contact_name), PII=true
  • Noodcontact telefoon (Pattern C → user_profiles.emergency_contact_phone), PII=true
  • Certificaten & vaardigheden (TAG_PICKER)
  • Motivatie (TEXTAREA, optional)
  • Sectie-voorkeuren (SECTION_PRIORITY)
  • Beschikbaarheid (AVAILABILITY_PICKER)
  • Toestemming gegevensverwerking (BOOLEAN, required, consent-tracked)

Mode rationale: draft_single — persons may fill across multiple sessions, but only one registration per person per event is meaningful.

freeze_on_submit: false. Organiser may want to add fields mid-campaign.

retention_days default: 1095 (3 years, matches typical volunteer data retention).

3.2.2 user_profile

Who creates: organiser per organisation (exactly one schema per organisation, seeded automatically via UserProfileDefault).

Who submits: authenticated user via portal (/profile page).

Submission flow:

  1. User opens profile; submission created on-demand if not exists
  2. All fields are Pattern A (entity-owned → user_profiles columns)
  3. User edits; on save, updateOrCreate on user_profiles
  4. FormSubmissionSubmitted fires on any update
  5. No confirmation email (user-initiated, no notification needed)

Canonical fields:

  • Bio (Pattern A → user_profiles.bio)
  • Profielfoto (Pattern A → user_profiles.photo_url, IMAGE_UPLOAD)
  • Noodcontact naam (Pattern A)
  • Noodcontact telefoon (Pattern A)

Mode: single (exactly one submission per user, updates overwrite).

freeze_on_submit: false.

retention_days: null (never anonymised — user profile persists while account exists; deletion happens via user-delete flow, see §13.4).

3.2.3 artist_profile

Who creates: org-level, one schema per organisation.

Who submits: organiser on behalf of artist (via artist management UI), or artist themselves via artist portal token.

Submission flow:

  1. Submission created when artist is created in system
  2. Organisers fill/update artist profile fields
  3. Public flow: artist clicks portal_token link, sees profile form
  4. On save, entity updates (Pattern A for most fields)
  5. FormSubmissionSubmitted fires

Canonical fields (initial set, extended when artist module lands):

  • Band/artist naam (Pattern A → artists.name)
  • Contact naam (Pattern A → artists.contact_name)
  • Contact e-mail (Pattern A → artists.contact_email)
  • Genre (SELECT, filterable=true)
  • Rider-notities (TEXTAREA)
  • Technische rider (FILE_UPLOAD, PDF)

Mode: single.

freeze_on_submit: false.

retention_days: null (artist relationships persist across events).

3.2.4 company_profile

Mirror of artist_profile but for company entities. One schema per org, companies get a row on creation. Pattern A/C mix for contact fields.

Canonical fields: contact_name, contact_email, contact_phone, website, company-type (supplier/partner/etc.), KvK-nummer, BTW-nummer.

3.2.5 artist_advance

Who creates: org-level schema (one per org), seeded from ArtistAdvanceDefault. Schema has section_level_submit=true.

Who submits: tour manager / artist representative via the engagement's portal_token (RFC-TIMETABLE v0.2 §5.3) — no authentication required.

Section model: advance_sections rows are scoped to engagement_id (per-event), not to artist_id (master). One master artist with two engagements (e.g. Vrijdag and Zaterdag of the same festival) advances each engagement on its own trajectory. Section-type categorisation is driven by the AdvanceSectionType enum (see RFC v0.2 §10 + App\Enums\Artist\AdvanceSectionTypeguest_list | contacts | production | custom). Section labels live in advance_sections.name (string 80). Default-section seeds are defined in Session 3 (ArtistAdvanceDefault).

Submission flow:

  1. Tour manager receives email with portal link (triggered by organiser)
  2. Opens link → engagement resolved from portal_token, submission fetched or created with the master Artist as subject (see §17.3)
  3. Form shown with the engagement's advance_sections
  4. Sections are filled independently; each section submits separately
  5. On section submit: FormSubmissionSectionSubmitted event fires
  6. Organiser reviews per section → section status approved/rejected/ changes_requested
  7. When all sections approved: FormSubmissionSubmitted fires at the submission level
  8. Integration: triggers accreditation generation per ARCH-07 (future)

Mode: draft_single.

freeze_on_submit: false during draft; true after all sections approved.

retention_days: 2555 (7 years, contractual retention).

3.2.6 supplier_intake

Who creates: org-level schema seeded from SupplierIntakeDefault. Uses section_level_submit=true.

Who submits: supplier via production_request.token.

Submission flow: analogous to artist_advance. Sections: Company info, Personnel list (TABLE_ROWS), Materials (TABLE_ROWS), Power requirements, Transport + logistics, Accreditation requests.

Integration with existing production_requests infrastructure (see §31.6).

3.2.7 incident_report

Who creates: org-level schema from IncidentReportDefault.

Who submits: any authenticated user (volunteer, crew, organiser) during an event. Also available via public form if organiser enables public token (rare — typically internal only).

Submission flow:

  1. User accesses via "Rapporteer incident" link in portal (event-scoped)
  2. Submission has subject_type=user (submitter), event context in metadata
  3. freeze_on_submit=true — once submitted, cannot be edited (integrity)
  4. Organiser reviews via incident dashboard; review_status workflow applies
  5. Integration: critical incidents notify org_admins via CrewliMailable
  6. schema_snapshot stored for legal retention

Canonical fields:

  • Tijdstip incident (DATETIME, required, defaults to now)
  • Locatie (TEXT, required)
  • Type incident (SELECT: medisch/veiligheid/schade/conflict/anders, filterable)
  • Ernst (SELECT: laag/middel/hoog/kritiek, filterable)
  • Betrokkenen (TEXTAREA)
  • Beschrijving (TEXTAREA, required)
  • Ondernomen actie (TEXTAREA, required)
  • Foto's (IMAGE_UPLOAD, multiple, max 5)
  • Politie/ambulance gebeld? (BOOLEAN)

Mode: multiple (same user can report multiple incidents).

freeze_on_submit: true.

retention_days: 3650 (10 years, legal minimum).

snapshot_mode: on_submit.

3.2.8 feedback

Who creates: per-event or per-org schema.

Who submits: volunteer / crew / public visitor (public optional).

Submission flow: simple form-submit, fires FormSubmissionSubmitted, organiser can browse responses.

Canonical fields: waardering (NUMBER 1-5), wat ging goed (TEXTAREA), wat kan beter (TEXTAREA), zou je terugkomen (BOOLEAN).

Mode: multiple.

retention_days: 365.

3.2.9 post_event_evaluation

Who creates: per-event schema, triggered by organiser after event closes.

Who submits: person who participated (subject = person).

Submission flow:

  1. Organiser clicks "Verstuur evaluatie" in event dashboard
  2. Emails sent to all approved persons (see §31.4)
  3. Person clicks link, fills form
  4. On submit: FormSubmissionSubmitted fires
  5. Aggregation dashboard populates from submissions

Canonical fields:

  • Algemene waardering (NUMBER 1-5)
  • Shift-waardering (NUMBER 1-5)
  • Was de briefing duidelijk? (NUMBER 1-5)
  • Wil je terugkomen? (BOOLEAN)
  • Opmerkingen (TEXTAREA)
  • Verbeterpunten (TEXTAREA)
  • Anoniem? (BOOLEAN) — if true, submitted_by_user_id cleared on submit

Mode: single (one evaluation per person per event).

snapshot_mode: on_submit (responses frozen post-event).

retention_days: 365.

3.2.10 signature_contract

Who creates: per-org, optionally per artist/event.

Who submits: user or artist via authenticated / token flow.

Submission flow:

  1. Schema contains PARAGRAPH with contract text + SIGNATURE field
  2. On submit: signature hashed (§9), stored
  3. submission locked (freeze_on_submit=true enforced)
  4. PDF can be generated later (out of scope for v1)

Mode: single.

retention_days: 2555 (7 years fiscal).

snapshot_mode: on_submit.

3.2.11 signature_code_of_conduct

Per-event schema. User must sign code-of-conduct before participating. Linked from portal "Gedragscode tekenen" action. Integration: person cannot claim shifts until code-of-conduct submission exists with status=submitted (see §31.5).

Fields: PARAGRAPH (code text from org config) + BOOLEAN (Ik ga akkoord)

  • SIGNATURE.

Mode: single per person per event.

3.2.12 signature_receipt

Per-event, per-person-per-accreditation. Used at accreditation desk: volunteer signs for received wristband / t-shirt / meal vouchers.

Fields: TABLE_ROWS (items received with quantities) + SIGNATURE.

Mode: multiple (different receipts at different moments).

freeze_on_submit: true.

3.2.13 absence_report

Who creates: per-event optional schema. If not present, absence is logged via simple status change on shift_assignment.

Who submits: person via portal when they realise they can't show up.

Submission flow:

  1. Person clicks "Ik kan toch niet komen" on shift detail
  2. Form opens, subject = person
  3. Fields: reason (SELECT), notes (TEXTAREA), affected_shifts (AVAILABILITY_PICKER showing their assigned shifts)
  4. On submit: for each affected shift, ShiftAssignment.status → cancelled with cancellation_source="volunteer_absence"
  5. FormSubmissionSubmitted fires; organiser notification sent

Mode: multiple.

Integration: see §31.3 (shifts) and §31.4 (email).

3.2.14 check_out_inventory

Accreditation-desk form used at end of event when volunteer returns gear.

Fields: TABLE_ROWS (items returned, condition per item), SIGNATURE of accreditation officer + volunteer.

Integration: links to accreditation module (ARCH-07, future) — at that point the pre-checkin items list is auto-populated.

3.2.15 public_complaint

Public form accessible via crewli.app/f/{token}. Captcha required.

Fields: name (optional), email (required), subject (TEXT), message (TEXTAREA), event (SELECT from org's public events).

Rate limit: 3 per IP per hour.

Integration: triggers email to configured complaints-mailbox (org-level config).

3.2.16 public_press_request

Similar to public_complaint but for press accreditation requests. Fields: outlet name, contact details, press card upload, event(s) requested. Captcha required.

3.2.17 public_rsvp

VIP RSVP form. Public URL with token. subject_type may be null (truly public) or person (specific VIP invited). Fields: name, email, attending (BOOLEAN), plus_ones (NUMBER), dietary_preference.

3.2.18 onboarding_wizard

Self-hosted meta-form. Used when a new organisation is created — the organisation-admin fills org details, brand, contact person, etc.

Uses: section_level_submit=true. Sections: Organisation basics, Brand & communication, Invite members, Create first event.

Pattern A for all fields — writes directly to organisations table (organisation is subject).

Once complete: schema is archived per organisation; never submitted again.

3.2.19 event_setup_wizard

Similar to onboarding_wizard but per-event. Sections: Basic details, Crowd types, Registration fields, Communication. Replaces the currently multi- page event setup with a guided form.

3.2.20 company_custom

Organisation-specific company fields. E.g., org tracks "preferred bank account" or "fire safety certified?" on companies. One schema per org, fields added by org_admin.

Pattern B for all custom fields (form-owned). Shows up in company detail as a tab.

3.2.21 artist_custom

Analogous to company_custom but for artists.

3.2.22 custom

See §17.3 — organisations define their own purposes via custom_purpose_slug. Subject type and mode must be explicitly set.


4. Core tables

4.1 form_schemas

Column Type Notes
id ULID PK
organisation_id ULID FK → organisations
owner_type string nullable polymorph: event / user_profile / artist / company / null
owner_id ULID nullable polymorph target
name string
slug string Canonical within org
purpose enum FormPurpose
custom_purpose_slug string nullable Required when purpose=custom (see §17.3)
description text nullable
is_published bool, default false
submission_mode enum FormSubmissionMode
public_token ULID nullable, unique For public schemas
public_token_previous ULID nullable For graceful rotation
public_token_rotated_at datetime nullable
submission_deadline datetime nullable
locale string, default 'nl' Primary locale; translations in form_fields.translations
settings JSON nullable Opaque UI config
version int, default 1 Bumped on any schema-affecting edit
snapshot_mode enum, default 'never' never / on_submit / always — see §14
freeze_on_submit bool, default false After first submitted submission, no more edits
retention_days int nullable After submitted_at + retention_days: anonymise
consent_version string nullable e.g. "privacy-v2"
section_level_submit bool, default false Enables sections-with-own-submit
auto_save_enabled bool, default false Client does periodic silent saves
max_submissions int nullable Optional cap for public schemas
created_by_user_id ULID FK nullable Audit: who created this schema
last_updated_by_user_id ULID FK nullable Audit: who last edited
edit_lock_user_id ULID FK nullable Pessimistic lock holder for collaborative editing
edit_lock_expires_at datetime nullable Auto-release after this time
created_at, updated_at, deleted_at Soft delete

Relations: hasMany form_fields, hasMany form_submissions, hasMany form_schema_webhooks, hasMany form_schema_sections, belongsTo organisation, morphsTo owner, belongsTo createdBy/lastUpdatedBy/editLockUser (User)

Indexes: (organisation_id, purpose), (owner_type, owner_id), (public_token) partial where not null, (public_token_previous) partial, (custom_purpose_slug) partial

Unique: (organisation_id, slug)

Activity log: yes — via spatie/laravel-activitylog on create/update/delete

4.2 form_fields

Column Type Notes
id ULID PK
form_schema_id ULID FK → form_schemas, cascadeOnDelete
form_schema_section_id ULID FK nullable → form_schema_sections (when section_level_submit)
field_type string One of FormFieldType or registered custom type (see §17.2)
slug string Unique within schema
label string Default-locale label
help_text text nullable
section string nullable Section header name (visual grouping, different from section_level_submit)
options JSON nullable Choice options
validation_rules JSON nullable min/max/regex/allowed_mime_types/etc.
is_required bool, default false
is_filterable bool, default false See §7
is_portal_visible bool, default true
is_admin_only bool, default false Convenience for common role restriction
is_unique bool, default false Value must be unique across submissions (DB-enforced via partial index; see §4.2.1)
is_pii bool, default false Marks field as personal data (governance, §13)
display_width enum: half/full
binding JSON nullable See §6
conditional_logic JSON nullable See §8
role_restrictions JSON nullable See §24
translations JSON nullable { "en": { "label": "...", "help_text": "...", "options": [...] } }
value_storage_hint enum, default 'json' json / string / number / date / bool — enables typed columns in form_values
review_required bool, default false Individual fields needing review (§15)
sort_order int
library_field_id ULID FK nullable → form_field_library (linked reusable definition, §17.4)
created_at, updated_at, deleted_at Soft delete preserves submission history

Relations: belongsTo form_schema, belongsTo form_schema_section (nullable), belongsTo libraryField (form_field_library), hasMany form_values

Indexes: (form_schema_id, sort_order), (form_schema_id, is_filterable), (library_field_id) partial

Unique: (form_schema_id, slug) on non-deleted

Activity log: yes — via spatie/laravel-activitylog

Soft delete: yes — deleting a field must not break historical submissions

4.2.1 [v1.2] is_unique enforcement

When is_unique=true, values for this field must be unique across all submissions of the same schema. Enforcement:

  • Application-level: FormValueService validates on write, rejects with 422 if duplicate exists among submitted submissions of the same schema
  • Database-level: partial UNIQUE index UNIQUE(form_field_id, value_indexed) WHERE form_field.is_unique = true AND form_submission.status = 'submitted' — This is implemented via a database-level trigger or application-enforced check because partial indexes with cross-table predicates are engine- specific. Preferred: application-enforced for portability, with a periodic integrity check job that flags violations for admin review.

4.3 form_submissions

Column Type Notes
id ULID PK
form_schema_id ULID FK → form_schemas
organisation_id ULID FK → organisations, cascade delete. Denormalized per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 Q2 — the single rapportage-hot exception. Populated by FormSubmissionObserver::creating from the schema parent.
event_id ULID FK nullable → events, null on delete. Denormalized per addendum Q2. Observer resolves from form_schemas.owner_id when owner_type = event; else from the active route's {event} parameter. Null for purposes without an event context (user_profile, signature_contract).
subject_type string nullable polymorph
subject_id ULID nullable polymorph target
submitted_by_user_id ULID FK nullable Authenticated submitter
public_submitter_name string nullable Public submitters
public_submitter_email string nullable Public submitters
public_submitter_ip string nullable For audit/security
public_submitter_ip_anonymised_at datetime nullable [v1.2] Set when IP is anonymised (default 30 days post-submit unless investigation flag)
status enum FormSubmissionStatus: draft / submitted / archived
review_status enum nullable null / pending_review / approved / rejected / changes_requested
reviewed_by_user_id ULID FK nullable
reviewed_at datetime nullable
review_notes text nullable
submitted_at datetime nullable
schema_version_at_submit int nullable The schema.version at submit time
schema_snapshot JSON nullable Full snapshot when schema.snapshot_mode dictates (§14)
is_test bool, default false Test/preview submissions excluded from reporting
submitted_in_locale string nullable Locale submitter used while filling in
opened_at datetime nullable First time the form was rendered
first_interacted_at datetime nullable First field focus/input
submission_duration_seconds int nullable opened_at → submitted_at
auto_save_count int, default 0 Debug/analytics counter
idempotency_key ULID nullable Client-provided key preventing duplicate submits
anonymised_at datetime nullable Set by anonymisation service (§13)
search_index text nullable Concatenated text values for full-text search (§23)
created_at, updated_at, deleted_at Soft delete

Relations: belongsTo form_schema, hasMany form_values, hasMany form_submission_section_statuses, hasMany form_submission_delegations, belongsTo submittedBy / reviewedBy (User), morphsTo subject

Indexes: (form_schema_id, status), (organisation_id, status) (addendum Q2 — dashboards + CSV exports aggregate by tenant + status), (event_id, status) (addendum Q2 — event-scoped reporting), (subject_type, subject_id), (submitted_by_user_id), (form_schema_id, review_status) partial where not null, FULLTEXT(search_index) (MySQL)

Unique: (form_schema_id, idempotency_key) partial where not null

Soft delete: yes

Events fired: See §17.1 — FormSubmissionCreated, FormSubmissionDraftUpdated, FormSubmissionSubmitted, FormSubmissionReviewed, FormSubmissionAnonymised, FormSubmissionArchived, FormSubmissionDeleted

4.4 form_values

Column Type Notes
id int AI PK — integer for join performance
form_submission_id ULID FK → form_submissions, cascadeOnDelete
form_field_id ULID FK → form_fields
value JSON Universal storage; interpretation via field_type
value_indexed string(255) nullable For single-value filtering (§7)
value_number decimal(15,4) nullable Populated when value_storage_hint=number
value_date date nullable Populated when value_storage_hint=date
value_bool bool nullable Populated when value_storage_hint=bool
value_anonymised bool, default false Set by anonymisation

Indexes: (form_submission_id, form_field_id), (form_field_id, value_indexed) partial where value_indexed not null, (form_field_id, value_number) partial where value_number not null, (form_field_id, value_date) partial where value_date not null

Unique: (form_submission_id, form_field_id)

Observer: On upsert, populates value_indexed / value_number / value_date / value_bool / form_value_options based on field.value_storage_hint and field.is_filterable

4.5 form_value_options (filter pivot for multi-value fields)

Column Type Notes
id int AI PK
form_value_id int FK → form_values, cascadeOnDelete
form_field_id ULID FK Denormalised for fast filtering
form_submission_id ULID FK Denormalised
option_value string(255) Single selected option

Indexes: (form_field_id, option_value), (form_submission_id), (form_value_id)

4.6 form_templates

Column Type Notes
id ULID PK
organisation_id ULID FK → organisations
name string
slug string
purpose enum FormPurpose — constrains target schemas
description text nullable
schema_snapshot JSON Full schema with fields, order, config
is_system bool true = shipped with Crewli
is_active bool Deactivate without deleting
created_at, updated_at

Indexes: (organisation_id, purpose, is_active)

Unique: (organisation_id, slug)

4.6.1 [v1.2] schema_snapshot JSON structure

The snapshot stored in form_templates.schema_snapshot AND in form_submissions.schema_snapshot follows this canonical shape:

{
  "schema_version": 3,
  "snapshot_created_at": "2026-04-17T14:00:00Z",
  "schema": {
    "name": "...",
    "slug": "...",
    "purpose": "event_registration",
    "description": "...",
    "locale": "nl",
    "freeze_on_submit": false,
    "section_level_submit": false,
    "consent_version": "privacy-v2",
    "settings": {}
  },
  "sections": [
    {
      "id": "01H...",
      "slug": "general",
      "name": "Algemeen",
      "sort_order": 1,
      "depends_on_section_slug": null,
      "required_for_schema_submit": true
    }
  ],
  "fields": [
    {
      "id": "01H...",
      "slug": "shirtmaat",
      "field_type": "SELECT",
      "label": "Shirtmaat",
      "help_text": null,
      "section_slug": null,
      "options": ["XS","S","M","L","XL","XXL"],
      "validation_rules": {"required": true},
      "is_required": true,
      "is_filterable": true,
      "is_pii": false,
      "binding": null,
      "conditional_logic": null,
      "translations": {},
      "value_storage_hint": "json",
      "sort_order": 5
    }
  ]
}

Rationale: using slugs (not IDs) in cross-references so snapshots are portable between orgs. library_field_id references are resolved to inline definitions at snapshot time (see §4.7).

Byte-stability (RFC-WS-6 v1.1, session 2.7)

The snapshot is audit-immutable. JSON content stored in form_submissions.schema_snapshot is canonicalized on write via App\Support\Json\JsonCanonicalizer::canonicalize() (recursive ksort on associative arrays; numeric-indexed lists preserve order). This guarantees that re-emits of the same logical content produce byte-identical JSON regardless of MySQL's JSON-column round-trip behavior. Critical for:

  • Audit-replay diffs (otherwise key-reorder shows up as false positives)
  • Webhook payload signing (HMAC requires byte-stable input; payload_snapshot and the delivery-time JsonCanonicalizer::encode re-encode produce the same bytes)
  • Activity log diff regression tests (field.updated old/new payloads land via FormField::logFieldChange which canonicalizes before withProperties())

Opaque-config columns (form_schemas.settings, form_schemas.translations) are NOT canonicalized — key order has no semantic meaning there. See SchemaSnapshotByteStableAcrossReemitsTest for the end-to-end contract.

4.7 form_field_library (cross-schema reusable definitions)

Column Type Notes
id ULID PK
organisation_id ULID FK → organisations
name string "Shirtmaat (standaard)"
slug string
field_type string
label string Default label when library field is inserted
help_text text nullable
options JSON nullable
validation_rules JSON nullable
default_is_required bool, default false
default_is_filterable bool, default false
default_binding JSON nullable
translations JSON nullable
description text nullable Admin-only description of intended use
usage_count int, default 0 Cached count of form_fields.library_field_id = this.id
is_system bool true = shipped with Crewli
is_active bool
created_at, updated_at

Relations: hasMany form_fields (via library_field_id)

Indexes: (organisation_id, field_type), (organisation_id, is_active)

Unique: (organisation_id, slug)

When an organiser inserts a library field into a schema, a new form_fields row is created with library_field_id = library.id and values copied from the library definition. The inserted field is independent thereafter — editing it does not affect the library, and vice versa. The library_field_id reference exists for analytics ("used 34 times") and for "update all copies" workflows later.

4.8 form_schema_sections (for section-level submit)

Only used when form_schemas.section_level_submit = true.

Column Type Notes
id ULID PK
form_schema_id ULID FK → form_schemas, cascadeOnDelete
slug string [v1.2] Added for snapshot portability
name string "Technical Rider"
description text nullable
sort_order int
submit_independent bool, default true Whether this section can be submitted independently
depends_on_section_id ULID FK nullable → self; section is locked until dependency is approved
required_for_schema_submit bool, default true If all required sections must be submitted+approved to consider schema submit complete

Indexes: (form_schema_id, sort_order)

Unique: (form_schema_id, slug)

4.8.1 [v1.2] Cycle detection

When saving a section with depends_on_section_id, FormSchemaService validates no cycle is created (depth-first traversal of depends_on_section_id chain). Rejected with 422 on cycle detection.

4.9 form_submission_section_statuses

Column Type Notes
id ULID PK
form_submission_id ULID FK → form_submissions, cascadeOnDelete
form_schema_section_id ULID FK → form_schema_sections
status enum draft / submitted / approved / rejected / changes_requested
submitted_at datetime nullable
reviewed_by_user_id ULID FK nullable
reviewed_at datetime nullable
review_notes text nullable

Unique: (form_submission_id, form_schema_section_id)

4.10 form_submission_delegations

For scenarios like "Nina fills in my advance on my behalf".

Column Type Notes
id ULID PK
form_submission_id ULID FK → form_submissions, cascadeOnDelete
delegated_to_user_id ULID FK → users
delegated_by_user_id ULID FK → users (the subject who granted)
granted_at datetime
revoked_at datetime nullable
message text nullable Optional context from delegator to delegatee

Indexes: (delegated_to_user_id, revoked_at), (form_submission_id)

4.11 form_schema_webhooks

Column Type Notes
id ULID PK
form_schema_id ULID FK → form_schemas, cascadeOnDelete
name string "Zapier — New registration"
trigger_event enum submission_created / submission_submitted / submission_reviewed / section_submitted / section_approved / section_rejected
url string (encrypted) Webhook endpoint; validated against allowlist/blocklist
secret string (encrypted) nullable HMAC secret for payload signing
is_active bool, default true
created_at, updated_at

Indexes: (form_schema_id, is_active)

4.12 form_webhook_deliveries

Column Type Notes
id ULID PK
form_schema_webhook_id ULID FK → form_schema_webhooks
form_submission_id ULID FK → form_submissions
trigger_event enum Same as webhook's trigger_event
status enum pending / delivered / failed / dead_letter
attempts int, default 0
last_attempt_at datetime nullable
response_status int nullable HTTP status code
response_body_excerpt text nullable First 1000 chars of response
next_retry_at datetime nullable
delivered_at datetime nullable
failed_permanently_at datetime nullable
payload_snapshot JSON What was sent (for replay/audit)

Indexes: (status, next_retry_at), (form_schema_webhook_id, status), (form_submission_id)

4.13 user_profiles (renamed from legacy volunteer_profiles, slimmed)

Column Type Notes
id ULID PK
user_id ULID FK unique → users, cascadeOnDelete
bio text nullable
photo_url string nullable
emergency_contact_name string nullable
emergency_contact_phone string nullable
reliability_score decimal(3,2), default 0.00 System-computed
is_ambassador bool, default false System-awarded
settings JSON nullable Strict: opaque UI/notification preferences only (see §4.13.1)
created_at, updated_at

Computed attributes (Laravel accessors):

  • last_submitted_atmax(form_submissions.submitted_at WHERE subject=this user AND status=submitted AND is_test=false)

Relations: belongsTo User

Indexes: (reliability_score)

Unique: (user_id)

No soft delete — tightly coupled to user; cascades on user delete

4.13.1 [v1.2] settings scope enforcement

user_profiles.settings is a strictly-scoped JSON column. Allowed top-level keys are defined in config/form_builder.php:

'user_profile_settings_whitelist' => [
    'ui.theme',
    'ui.sidebar_collapsed',
    'ui.time_format',
    'notifications.email_digest',
    'notifications.shift_reminders',
    'notifications.event_updates',
],

Forbidden: any queryable business attribute, org-specific preferences (those belong in organisation_user pivot), anything identity-related.

Enforcement: UserProfileSettingsValidator custom rule on the update-request. Rejects keys not in whitelist with 422.

Locale lives on users.locale (already exists). Not duplicated here.


4.14 Multi-tenancy scope chain

Per ARCH-CONSOLIDATION-ADDENDUM-2026-04-24 §Q2, form-builder child tables resolve tenancy through their parent via the declarative tenantScopeStrategy() method on each model. OrganisationScope's resolver walks parents recursively (max 3 hops) until it reaches a column-based strategy (direct organisation_id, or a legacy $organisationScopeColumn bridge like event_id or festival_section_id).

The chains for the nine form-builder child models are:

Model Strategy Hops to org
FormSubmission column: organisation_id (denormalized, §4.3) 0
FormSchema column: organisation_id (existing) 0
FormSchemaSection via: FormSchema, fk: form_schema_id 1
FormField via: FormSchema, fk: form_schema_id 1
FormSchemaWebhook via: FormSchema, fk: form_schema_id 1
FormSubmissionSectionStatus via: FormSubmission, fk: form_submission_id 1
FormSubmissionDelegation via: FormSubmission, fk: form_submission_id 1
FormWebhookDelivery via: FormSubmission, fk: form_submission_id 1
FormValue via: FormSubmission, fk: form_submission_id 1
FormValueOption via: FormValue, fk: form_value_id → FormSubmission → organisation_id 2

The same work package extended scope coverage to five event-data models outside the form-builder domain:

Model Strategy Notes
ShiftAssignment via: Shift, fk: shift_id Shift uses legacy festival_section_id bridge
ShiftWaitlist via: Shift, fk: shift_id
VolunteerAvailability via: TimeSlot, fk: time_slot_id TimeSlot uses legacy event_id bridge
PersonSectionPreference via: FestivalSection, fk: festival_section_id FestivalSection uses legacy event_id bridge
PersonIdentityMatch via: Person, fk: person_id Person uses legacy event_id bridge

Callers that need cross-org queries (public form endpoints, admin dashboards, anonymisation retention jobs) must use ->withoutGlobalScope(OrganisationScope::class) explicitly — see PublicFormSchemaResource::toArray() for the canonical pattern on loadMissing(['fields' => fn ($q) => $q->withoutGlobalScope(...)]).


5. FormFieldType catalogue

5.1 Built-in types

Type Stored value UI widget Filterable?
TEXT string VTextField single-line Yes
TEXTAREA string VTextarea No
EMAIL string VTextField type=email Yes
PHONE string (E.164) VTextField Yes
NUMBER number VTextField type=number Yes (via value_number)
DATE ISO date VDatePicker Yes (via value_date)
DATETIME ISO datetime VDatePicker + time Yes
BOOLEAN bool VSwitch Yes (via value_bool)
RADIO string (one option) Radio group Yes
SELECT string VSelect Yes
MULTISELECT string[] VSelect multiple Yes via form_value_options
CHECKBOX_LIST string[] Checkbox group Yes via form_value_options
FILE_UPLOAD string (path) VFileInput No
IMAGE_UPLOAD string (path) VFileInput images only No
SIGNATURE object (§9) Signature pad No
TAG_PICKER string[] (tag IDs) Tag autocomplete Yes via user_organisation_tags
HEADING no value H3 n/a
PARAGRAPH no value Prose block n/a
URL string VTextField URL Yes
SECTION_PRIORITY { section_id, priority }[] Drag-to-prioritise No
AVAILABILITY_PICKER ULID[] (time_slot IDs) Checkbox per slot No
TABLE_ROWS { [col_slug]: value }[] Dynamic rows editor No

Custom field types registered via CustomFieldTypeRegistry (§17.2) extend this list dynamically. The field_type column is stored as string, not DB enum, to allow this.

5.2 [v1.2] Mapping to value_storage_hint

Field type Recommended value_storage_hint
TEXT, TEXTAREA, EMAIL, PHONE, URL string
NUMBER number
DATE, DATETIME date
BOOLEAN bool
RADIO, SELECT string
Everything else json (default)

The hint is suggestive, not enforced — organiser can override. Observer populates typed columns accordingly.


6. Field binding

6.1 The three patterns

Pattern A — Entity-owned (binding.mode = "entity_owned"): Value lives in an entity column. Form field is a rendering surface. No form_values row created. Reading: entity column. Writing: entity column.

Pattern B — Form-owned (default, binding = null): Value lives in form_values. No entity column involved. Pure dynamic EAV.

Pattern C — Mirrored (binding.mode = "mirrored"): Value written to entity column AND form_values. Entity is source of truth going forward; form_values is historical audit.

6.2 Entity column registry

Server-side config file config/form_binding.php:

return [
    'user_profile' => [
        'bio' => ['type' => 'text', 'label' => 'Bio', 'writable' => true],
        'photo_url' => ['type' => 'image', 'label' => 'Profielfoto', 'writable' => true],
        'emergency_contact_name' => ['type' => 'string', 'label' => 'Noodcontact naam', 'writable' => true],
        'emergency_contact_phone' => ['type' => 'string', 'label' => 'Noodcontact telefoon', 'writable' => true],
    ],
    'person' => [
        'first_name' => ['type' => 'string', 'label' => 'Voornaam', 'writable' => true],
        'last_name' => ['type' => 'string', 'label' => 'Achternaam', 'writable' => true],
        'email' => ['type' => 'string', 'label' => 'E-mail', 'writable' => true],
        'phone' => ['type' => 'string', 'label' => 'Telefoon', 'writable' => true],
        'date_of_birth' => ['type' => 'date', 'label' => 'Geboortedatum', 'writable' => true],
        'admin_notes' => ['type' => 'text', 'label' => 'Notities', 'writable' => true, 'admin_only' => true],
    ],
    'company' => [
        'name' => ['type' => 'string', 'label' => 'Bedrijfsnaam', 'writable' => true],
        'contact_first_name' => ['type' => 'string', 'label' => 'Contact voornaam', 'writable' => true],
        'contact_last_name' => ['type' => 'string', 'label' => 'Contact achternaam', 'writable' => true],
        'contact_email' => ['type' => 'string', 'label' => 'Contact e-mail', 'writable' => true],
        'contact_phone' => ['type' => 'string', 'label' => 'Contact telefoon', 'writable' => true],
    ],
    'artist' => [
        // populated when artist module lands
    ],
    'organisation' => [
        'name' => ['type' => 'string', 'label' => 'Organisatienaam', 'writable' => true],
        'slug' => ['type' => 'string', 'label' => 'Slug', 'writable' => true],
        'contact_name' => ['type' => 'string', 'label' => 'Contactpersoon', 'writable' => true],
        'contact_email' => ['type' => 'string', 'label' => 'Contact-e-mail', 'writable' => true],
        'phone' => ['type' => 'string', 'label' => 'Telefoon', 'writable' => true],
        'website' => ['type' => 'string', 'label' => 'Website', 'writable' => true],
    ],
];

Only registered columns are valid binding targets. Form Request validates at save time.

6.3 Binding row specification

Bindings live in the relational form_field_bindings table (see §6.7). The columns on a row are:

Column Type Notes
owner_type string(40) morph alias: form_field or form_field_library
owner_id ULID parent row
target_entity string(50) e.g. person, user_profile, company, organisation, artist
target_attribute string(100) e.g. email, first_name, emergency_contact_phone
mode string(20) FormFieldBindingMode enum: entity_owned or mirrored
sync_direction string(30) null Pattern C only (e.g. write_on_submit); null for Pattern A
merge_strategy string(20) FormFieldBindingMergeStrategy enum; default overwrite
trust_level tinyint unsigned 0100, default 50; WS-6 consumer
is_identity_key bool default false; WS-6 person-matching

Pattern B is represented by the absence of a row; only Pattern A and Pattern C create rows.

Snapshot embedding (form_submissions.schema_snapshot, §4.6.1) continues to embed bindings inline in the ARCH JSON shape. The snapshot writer serialises rows via FormFieldBindingService::toJsonShape:

// Pattern B (no row)
"binding": null

// Pattern A
"binding": {
  "mode": "entity_owned",
  "entity": "user_profile",
  "column": "bio"
}

// Pattern C
"binding": {
  "mode": "mirrored",
  "entity": "user_profile",
  "column": "emergency_contact_name",
  "sync_direction": "write_on_submit"
}

Historical snapshots (written pre-WS-5a) use the same JSON shape, so snapshot readers keep working unchanged.

6.4 Read/write semantics per pattern

Operation Pattern A Pattern B Pattern C
Initial render Read entity column Read form_value (null if new) Read entity column
Save draft Update entity Upsert form_value Update entity + upsert form_value
Final submit Update entity Upsert form_value Update entity + upsert form_value
Historical view Not possible — always current Show stored form_value Show stored form_value
form_values row? No Yes Yes
Filter source Entity column form_values.value_indexed / value_number / etc. Entity column (preferred)

6.5 Binding-change safety

Changing a field's binding is a DANGEROUS OPERATION. Rules:

  • If schema has zero submissions: free to change
  • If schema has submissions but all are drafts: allowed with warning
  • If schema has submitted submissions: REJECTED unless organiser provides explicit ?force_binding_change=true query param AND the change is logged with elevated audit level
  • Changing binding from B → A/C: orphans all historical form_values for that field (they remain in DB but no longer the source of truth). Documented in activity log.
  • Changing binding from A/C → B: entity columns retain their values; new submissions start storing in form_values again

Always logged via activity log with old/new binding for audit.

6.6 Cross-entity binding

Pattern C fields on event_registration schemas (subject = person) CAN bind to user_profile columns IF person.user_id is set. When user_id is null (external person), the mirror write is skipped gracefully and logged. form_values row is still written in both cases.

6.7 Relational binding table

Table: form_field_bindings — columns defined in §6.3.

Discriminator (WS-5a commit 1, Uitvoering per addendum Q3): polymorphic morph (owner_type / owner_id) with morph-map aliases form_field and form_field_library. The paired-nullable-FK alternative was rejected — MySQL 8 has no partial-unique support, and the remaining WS-5 sub-work- packages (5b form_field_validation_rules, 5d form_field_options) reuse the same owner-discriminator shape; a single idiomatic pattern across the family beats per-table workarounds.

Multi-tenancy (FormFieldBindingScope): OrganisationScope's declarative FK-chain resolver (addendum Q2) walks direct or single-FK parents; it cannot walk a morph parent. FormFieldBindingScope builds the equivalent UNION:

owner_id ∈ (
  SELECT id FROM form_fields
    WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?)
  UNION
  SELECT id FROM form_field_library
    WHERE organisation_id = ?
)

Organisation context resolves the same way OrganisationScope does — explicit override via constructor, then route parameter organisation (and the event fallback). CLI, queues, and unauthenticated flows skip the scope. Escape hatch: FormFieldBinding::withoutGlobalScope(FormFieldBindingScope::class).

Post-WS-5d, FormFieldBindingScope is a marker subclass of FormFieldChildTableMorphScope; the shared UNION-over-two-owner-chains logic lives in the abstract base. See app/Models/Scopes/FormFieldChildTableMorphScope.php. Identity- preserving extraction landed after the four sibling scopes existed (rationale + Phase A diff verification in ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §"Uitvoering — base scope-class extractie").

Service boundary (FormFieldBindingService): all writes go through the service — no controller fills bindings directly on the model. The service owns:

  • bindingsFor(owner) — eager, scope-aware fetch.
  • replaceBindings(owner, specs) — transactional delete + insert; validates every spec against the entity-column registry (config/form_binding.php) and against FormFieldBindingMode / FormFieldBindingMergeStrategy enums. Logs field.bindings_replaced on the owning field.
  • copyBindings(library, field) — row-clone on FormFieldService::insertFromLibrary (Q3 row-copy mandate). Every column is preserved; only owner_type / owner_id change.
  • toJsonShape(binding) — single source of truth for serialising a row into the ARCH §6.3 JSON shape. Consumed by the snapshot writer (FormSubmissionService::buildSnapshot) and by API resources (FormFieldResource, FormFieldLibraryResource).

Cascade (FormFieldBindingsCascadeObserver): bindings are physical state, not audit. On soft- or hard-delete of the owner (FormField::delete() or FormFieldLibrary::delete()), the observer physically deletes the owner's bindings. No soft-delete on the binding table itself.

TODO (out of WS-5a scope, FORM-BINDING-SNAPSHOT-MULTI): the snapshot writer embeds at most one binding per field. Multi-binding on a single field (per §6.1 future scenarios) needs a snapshot shape decision.

Activity log events. Changing a field's bindings emits two entries on the parent FormField subject:

  • field.updated — payload includes old.binding / new.binding shapes reconstructed from the relational table via FormFieldBindingService::toJsonShape(). Preserves the pre-WS-5a audit-consumer contract for downstream tooling that parses field.updated diffs.
  • field.bindings_replaced — the semantic binding-change event, emitted by FormFieldBindingService::replaceBindings().

Both fire for the same semantic change. Aggregate queries over activity-log event counts should filter on one, not both. Downstream consumers that migrate to the semantic event can stop listening to field.updated binding diffs once all their callers have moved.


7. Filter architecture

A form_fields.is_filterable = true marks a field as queryable in list views.

7.1 Filter strategy per field type

Field type Filter mechanism Storage
TEXT, EMAIL, PHONE, URL LIKE on value_indexed form_values.value_indexed
NUMBER Range on value_number form_values.value_number
DATE, DATETIME Range on value_date / value_indexed form_values.value_date / value_indexed
BOOLEAN Exact on value_bool form_values.value_bool
RADIO, SELECT Exact/IN on value_indexed form_values.value_indexed
MULTISELECT, CHECKBOX_LIST JOIN on form_value_options form_value_options
TAG_PICKER JOIN on user_organisation_tags existing tag pivot
Pattern A/C entity-owned WHERE on entity column entity column
TEXTAREA, FILE_*, SIGNATURE, HEADING, PARAGRAPH, TABLE_ROWS, SECTION_PRIORITY, AVAILABILITY_PICKER Not filterable n/a — is_filterable forced to false

7.2 Observer mechanics

When a form_value is upserted for a field with is_filterable=true:

  • Single-value types: value_indexed = cast to string(255), truncated with warning log if longer
  • Number type: value_number = cast to decimal(15,4)
  • Date type: value_date = cast to date
  • Bool type: value_bool = cast to bool
  • Multi-value types: rebuild form_value_options (delete all, insert current)

When is_filterable=false: all indexed columns NULL, pivot rows removed.

When toggling is_filterable: queued job backfills (or clears) existing submissions. Job is observable (metric on queue depth).

7.3 Filter registry endpoint

Example: Personen-module

GET /api/v1/organisations/{org}/persons/filter-registry?event_id={ulid?}

Response:

{
  "data": [
    { "source": "entity_column", "key": "crowd_type_id", "label": "Crowd Type", "field_type": "SELECT", "options": [...] },
    { "source": "entity_column", "key": "status", "label": "Status", "field_type": "SELECT", "options": [...] },
    { "source": "tags", "key": "tags", "label": "Vaardigheden", "field_type": "TAG_PICKER", "options": [...] },
    {
      "source": "form_field",
      "key": "form_field:01HZ...",
      "form_field_id": "01HZ...",
      "schema_slug": "...",
      "label": "Shirtmaat",
      "field_type": "SELECT",
      "options": ["XS","S","M","L","XL","XXL"]
    }
  ]
}

Caching: response cached on (organisation_id, purpose, event_id?) with invalidation via form_schema/form_field activity log events.

7.4 [v1.2] Filter registry — entity column sources

The entity_column source in the filter registry is separate from the binding registry (§6.2). Filterable entity columns per context are defined in config/form_filter_registry.php:

return [
    'persons' => [
        'crowd_type_id' => ['label' => 'Crowd Type', 'field_type' => 'SELECT', 'options_source' => 'crowd_types'],
        'status' => ['label' => 'Status', 'field_type' => 'SELECT', 'options_enum' => PersonStatus::class],
        'is_blacklisted' => ['label' => 'Uitgesloten', 'field_type' => 'BOOLEAN'],
    ],
    'companies' => [...],
    'events' => [...],
];

Rationale: not every bindable column is filterable (admin_notes shouldn't be filtered), and not every filterable column is bindable (PersonStatus is enum-driven, not user-editable via form).

7.5 Applying filters in list endpoints

A generic FilterQueryBuilder service:

  • Takes URL params and the filter-registry definition
  • Joins the appropriate tables based on source
  • Applies WHERE/HAVING as appropriate
  • Respects role-based access (a filter on admin_only field is rejected for non-admin requester with 403)

7.6 Organiser UX

Form-builder UI shows "Gebruik als filter in overzichten" checkbox per field. Disabled with tooltip for non-filterable types. Hint on enabling: "Dit wordt extra geïndexeerd voor snelle filtering — alleen aanvinken voor velden die je echt als filter gebruikt."


8. Conditional logic

8.1 Tree structure

Per-field visibility rules are a boolean tree: mixed condition leaves and sub-groups under a parent all (AND) / any (OR) group. The external JSON contract (snapshot writer + API resources) renders the tree under a show_when wrapper:

{
  "show_when": {
    "all": [
      { "field_slug": "has_allergies", "operator": "equals", "value": true }
    ]
  }
}

Groups nest arbitrarily. Leaves reference sibling fields by field_slug within the same schema. Seed-scan (2026-04-26, Phase A) confirmed nesting depth ≤ 2 in the wild; the architecture tolerates deeper nesting within the scope-cap ceiling.

8.2 Relational tables (WS-5c)

Pre-WS-5c the tree lived in a form_fields.conditional_logic JSON column. WS-5c split it into two semantic-pure tables:

  • form_field_conditional_logic_groups — tree nodes (AND/OR), adjacency- list nesting via parent_group_id. Columns: id ULID PK, form_field_id FK, parent_group_id nullable FK self, operator (FormFieldConditionalLogicGroupOperator enum), sort_order, timestamps. Indexes: (form_field_id), (parent_group_id, sort_order).
  • form_field_conditional_logic_conditions — leaves. Columns: id ULID PK, group_id FK, field_slug string(100), comparison_operator (FormFieldConditionalLogicConditionOperator enum), value JSON nullable, sort_order, timestamps. Indexes: (group_id, sort_order), (field_slug).

No polymorphic morph. Per addendum Q3, only FormField is in scope for conditional_logic — the library doesn't carry conditional_logic and is not mirrored. Simple form_field_id FK, not owner_type/owner_id.

Multi-tenancy. Both tables use the Q2 declarative FK-chain resolver via tenantScopeStrategy():

  • Group chain (3 hops): group → field → schema → organisation_id
  • Condition chain (4 hops): condition → group → field → schema → organisation_id

The condition chain is 4 hops and requires the OrganisationScope cap to be ≥ 4. WS-5c raised the global cap from 3 to 5 to accommodate the chain (and to give headroom for future deeper trees).

Cascade. DB-level ON DELETE CASCADE on form_field_id and parent_group_id handles hard deletes. The shared FormFieldChildTablesCascadeObserver physically deletes groups on FormField soft- or hard-delete; conditions cascade via group_id. Bindings, validation rules, configs and now conditional-logic groups are all current state (not audit) — they never carry soft-delete semantics of their own.

8.3 Service boundary

FormFieldConditionalLogicService is the only writer. No controller writes groups or conditions directly on a model.

  • logicFor(field) — depth-limited eager-load of the full tree. Bounded to 5 levels to match the scope-cap ceiling.
  • replaceLogic(field, tree) — transactional: structure validation, operator enum enforcement, field_slug existence check (against sibling fields in the same schema), cycle detection, then delete- and-insert. Emits field.conditional_logic_replaced on the FormField subject.
  • toJsonShape(root) — single source of truth for serialising a tree back into the ARCH §8.1 {show_when: {...}} shape. Consumed by FormSubmissionService::buildSnapshot and by FormFieldResource, PublicFormSchemaResource. Deterministic interleave of sub-groups and conditions by (sort_order, id).
  • assertSpecsValid(tree) — public guard called by the Store/Update FormRequests' after() hook. Rejects bad specs at the HTTP boundary before any write.
  • assertNoCycles(field, tree) — see §8.5.

FormFieldService::insertFromLibrary does not propagate conditional logic — the library carries none (addendum Q3).

8.4 Operator catalogues

Group operators (FormFieldConditionalLogicGroupOperator DB-backed enum):

Value Semantic
all AND
any OR

Comparison operators (FormFieldConditionalLogicConditionOperator DB-backed enum — catalogue confirmed by Phase A seed-scan against the frontend evaluator in packages/form-schema/src/composables/useConditionalLogic.ts):

Value Reads value? Notes
equals yes
not_equals yes
contains yes substring (strings) / membership (arrays)
not_contains yes
in yes (array)
not_in yes (array)
greater_than yes (numeric)
less_than yes (numeric)
empty no value column stored as NULL; service enforces
not_empty no value column stored as NULL; service enforces

8.5 Cycle detection

Cross-field cycle detection (contract preserved from pre-WS-5c FormFieldService::assertNoConditionalCycle, implementation moved to FormFieldConditionalLogicService::assertNoCycles).

Algorithm: build a slug → list-of-dependent-slugs adjacency over every sibling field in the schema (reads the relational tree — post-WS-5c source of truth) plus the proposed tree for the subject field. DFS from the subject's slug; a back-edge raises CyclicDependencyException. Controller maps the exception to 422.

Tree-internal cycles are structurally impossible via the adjacency-list nesting (parent_group_id is a single ancestor).

8.6 Activity log

Matches the WS-5a/b pattern. Two entries emit on a logic change:

  • field.updated — payload includes old.conditional_logic / new.conditional_logic shapes reconstructed from the relational tree via toJsonShape. Preserves the pre-WS-5c audit-consumer contract.
  • field.conditional_logic_replaced — the semantic event, emitted inside replaceLogic().

FormField subject only. No library mirror — matches §6.7 WS-5a and §17.4.2 WS-5b on the "library-level changes silent in activity log" convention.

8.7 Legacy JSON migration (WS-5c)

The WS-5c backfill migration (2026_04_26_100002_backfill_form_field_conditional_logic.php) translates pre-WS-5c form_fields.conditional_logic JSON into rows. Strict dispatch — no guessing, no silent drops:

  • Top-level keys other than show_when: FAIL the migration. Phase A seed-scan (2026-04-26) confirmed only show_when exists in the wild.
  • Comparison operators outside the 10-case catalogue: FAIL.
  • Group with no children / non-array child / missing all/any: FAIL.

Pre-WS-5c data is assumed acyclic — the JSON-era save-time cycle check enforced that. The backfill does NOT re-run cycle detection across the whole schema. Post-backfill the service enforces going forward.

Rollback reconstructs the canonical JSON shape from the relational tree (stable (sort_order, id) ordering) and writes it back to form_fields.conditional_logic (still present pre-drop migration), then clears the relational tables. The forward+back pair is safe as a unit; a partial rollback that pops just this migration but leaves its create-table siblings is not a supported state.


9. Signature field

Stored value JSON structure:

{
  "file_path": "signatures/01HZ.../signature.png",
  "disk": "s3",
  "signed_at": "2026-04-17T14:23:11Z",
  "signer_name": "Bert Hausmans",
  "signer_ip": "10.0.0.1",
  "hash": "sha256:..."
}

Hash = SHA-256 of file_bytes + signed_at + signer_name + signer_ip. Computed server-side on submit.

Once submitted, a signature form_value is immutable. The whole submission transitions to an archived-locked state preventing further edits.

9.1 [v1.2] Storage disk configuration

File paths relative to disk specified in signature value. Disk comes from config('filesystems.default') unless overridden per field:

"validation_rules": { "storage_disk": "s3-private" }

9.2 [v1.2] Signature verification

SignatureVerificationService::verify(FormValue $value): bool:

  • Reads file bytes from stored disk + path
  • Recomputes SHA-256 using stored signed_at/signer_name/signer_ip
  • Compares with stored hash
  • Returns true/false

Used by: admin UI ("Verify integrity" button on submitted signatures), legal-export endpoint (out of scope for v1, but service exists from day 1).

Integrity-mismatch triggers activity log entry at warning level AND notification to org_admin.


10. Public token flow

Schemas with public purposes get a public_token ULID.

URL: https://app.crewli.app/f/{public_token}

Rules:

  • GET on token URL: returns schema + empty submission state
  • POST on token URL: creates submission; subject_type = null (or derived from token)
  • No authentication required
  • Rate-limited per IP per schema per hour (configurable, default 5)
  • Captcha REQUIRED for truly public purposes: public_complaint, public_press_request
  • Captcha NOT required for entity-token flows (artist_advance via artist.portal_token; supplier_intake via production_request.token)
  • public_submitter_email: collected but never echoed in public responses

Token rotation: setting a new public_token moves current to public_token_previous and stamps public_token_rotated_at. Requests using the previous token are accepted for a grace period (default 7 days), allowing users to complete already-opened forms. After grace: old token returns 410 Gone.

10.1 [v1.2] Captcha provider

Cloudflare Turnstile (free, privacy-first, EU-friendly). Configuration in config/form_builder.php:

'captcha' => [
    'provider' => 'turnstile',
    'site_key' => env('TURNSTILE_SITE_KEY'),
    'secret_key' => env('TURNSTILE_SECRET_KEY'),
    'required_for_purposes' => ['public_complaint', 'public_press_request'],
],

Frontend embeds Turnstile widget. Backend validates token via CaptchaVerificationService::verify(string $token, string $remoteIp): bool before allowing submission.

Invalid captcha → 422 with clear error.

10.2 [v1.2] Rate limit storage

Uses Laravel's native RateLimiter facade. Backing store is config('cache.default') — typically Redis in production, array-cache in tests.

Rate limit key format: form-submit:{public_token}:{ip}

When limit hit: 429 Too Many Requests, with Retry-After header.

10.3 [v1.2] public_submitter_ip retention

public_submitter_ip is stored for audit/abuse-prevention. Automatically anonymised after 30 days post-submission by scheduled job (configurable via config/form_builder.php.public_submitter_ip_retention_days).

When anonymised: IP replaced with "[anonymised]" and public_submitter_ip_anonymised_at set.

Exception: if submission is flagged for investigation (organiser marks it), anonymisation is paused until flag is cleared.

10.4 [v1.3] Public submission lifecycle — draft / save / submit split

S2c split the atomic "one-POST does everything" flow into three REST endpoints so the portal can auto-save drafts without firing submit-events on every keystroke:

POST   /api/v1/public/forms/{public_token}/submissions
  Body: { idempotency_key (required, 630 chars),
          opened_at?, submitted_in_locale?,
          public_submitter_name?, public_submitter_email? }
  Creates a draft. Returns PublicFormSubmissionResource.
  Duplicate POST with the same idempotency_key returns the existing
  draft as HTTP 200 (vs 201 for fresh). Race-safe via UNIQUE
  (form_schema_id, idempotency_key).

PUT    /api/v1/public/forms/{public_token}/submissions/{submission_id}
  Body: { values: { <slug>: <value>|<array>, ... }, first_interacted_at? }
  Auto-save — partial updates allowed, only provided slugs are
  written. Status stays 'draft'. auto_save_count increments per call;
  FormSubmissionDraftUpdated event fires. Rule layer is relaxed
  (nullable + type checks); service layer enforces
  form_fields.validation_rules (min/max/regex/unique).

POST   /api/v1/public/forms/{public_token}/submissions/{submission_id}/submit
  Body: { values?: {...}, captcha_token? }
  Final submit. Merges body values with already-saved values, runs
  STRICT rule set (required, in:options, types, min/max) against the
  merged map. On success: status draft → submitted, schema_snapshot
  stored per form_schemas.snapshot_mode, schema_version_at_submit set,
  fires FormSubmissionSubmitted (→ §31.10 tag sync, §31.1 identity
  match). Rate-limited per
  (public_token, ip) per hour via RateLimiter 'form-submit:TOKEN:IP'.

Schema drift detection. form_submissions.schema_version_at_open is stamped at draft-create time; schema_version_at_submit is stamped at submit. Any difference (or — for active drafts — a difference against current schema.version) surfaces in PublicFormSubmissionResource.schema_drift: true so the portal can warn "this form has changed since you started".

Access rules.

  • Submission must belong to the resolved schema (URL (public_token, submission_id) pair must match).
  • Submission must be status = draft on both PUT and POST-submit — 409 SUBMISSION_ALREADY_SUBMITTED otherwise.
  • Rotated-token grace window applies the same way as the GET: resolve via public_token first, then public_token_previous (rejected with 410 TOKEN_EXPIRED if past the hard-coded 7-day grace). Making the window configurable is BACKLOG FORM-04.

Error envelope (D6). Every public form endpoint responds with the shared shape:

{ "message": "...", "code": "...", "errors"?: {"values.slug": ["..."]} }

Codes: SCHEMA_NOT_FOUND, TOKEN_EXPIRED, TOKEN_REVOKED, SCHEMA_UNPUBLISHED, SUBMISSION_ALREADY_SUBMITTED, RATE_LIMITED (carries Retry-After header), VALIDATION_FAILED.

Dependency-data endpoints. The public GET /{public_token} embeds available_tags per TAG_PICKER field. AVAILABILITY_PICKER and SECTION_PRIORITY use sibling read endpoints:

  • GET /{public_token}/time-slots — VOLUNTEER person_type only, festival-parent query surfaces parent + children time slots.
  • GET /{public_token}/sectionsshow_in_registration=true + type=standard, dedup by name across festival children.

11. Migration plan

Breaking change. No dual-support phase. Frontend updated in same PR.

11.1 Migration order

  1. Create new tables: form_schemas, form_submissions, form_value_options, form_field_library, form_schema_sections, form_submission_section_statuses, form_submission_delegations, form_schema_webhooks, form_webhook_deliveries, user_profiles
  2. Rename: registration_form_fieldsform_fields; add form_schema_id (nullable), keep event_id temporarily
  3. Rename: person_field_valuesform_values; add form_submission_id (nullable), keep person_id + registration_form_field_id temporarily
  4. Rename: registration_field_templatesform_templates
  5. Run data-migration script (§11.2)
  6. Drop legacy columns: form_fields.event_id, form_values.person_id, form_values.registration_form_field_id
  7. Apply new indexes

Each step is a separate migration file with reversible down().

11.2 Data migration script

Runs per organisation inside a transaction. Fails fast on unexpected shapes.

For each distinct event_id in (legacy) form_fields:
  1. Create form_schemas row:
     - organisation_id = event.organisation_id
     - owner_type = 'event', owner_id = event.id
     - purpose = 'event_registration'
     - name = event.name + ' registratie'
     - slug = event.slug + '-registratie'
     - is_published = (event status allows it)
     - submission_mode = 'draft_single'
     - version = 1
     - snapshot_mode = 'never'
     - created_by_user_id = first event_user_roles.user_id WHERE role=admin
  2. Update form_fields rows for that event:
     - form_schema_id = newly created schema's id
     - Preserve slug, field_type, config
     - Set is_pii via heuristic (see §11.2.1)
  3. For each distinct person with form_values for this event:
     - Create form_submissions:
       - form_schema_id = schema.id
       - subject_type = 'person', subject_id = person.id
       - submitted_by_user_id = person.user_id (may be null)
       - status = 'submitted' (if person.status >= applied) else 'draft'
       - submitted_at = MIN(form_values.created_at for that person)
       - schema_version_at_submit = 1
       - is_test = false
     - Update form_values:
       - form_submission_id = submission.id
       - For value_storage_hint != 'json': backfill typed columns
       - For is_filterable fields: backfill value_indexed / form_value_options
  4. For each registration_field_template:
     - Compute schema_snapshot
     - Create form_templates row with purpose='event_registration'

After all organisations processed:
  - Seed system templates per org (idempotent)
  - Seed system person_tags per org (idempotent)
  - Seed form_field_library system fields per org (idempotent)

Verification queries:
  - Count old registration_form_fields = count new form_fields
  - Count old person_field_values = count new form_values
  - Every form_field has non-null form_schema_id
  - Every form_value has non-null form_submission_id
  - Sample submission round-trip: render matches old view
  - Orphan check: no form_values with null form_submission_id
  - Orphan check: no form_values referencing non-existent form_field_id

11.2.1 [v1.2] PII heuristic

For legacy fields without is_pii flag:

$piiSlugPatterns = [
    'email', 'phone', 'telefoon', 'adres', 'address',
    'emergency_contact', 'noodcontact', 'noodnummer',
    'geboort', 'birthdate', 'birth_date', 'dob',
    'allergie', 'allergy', 'medisch', 'medical',
    'dieet', 'diet',
    'toegangs', 'access',
    'bsn', 'social_security',
    'iban', 'bank',
];

$piiFieldTypes = ['EMAIL', 'PHONE'];

$isPii = in_array($field->field_type, $piiFieldTypes)
    || collect($piiSlugPatterns)->contains(fn($p) => str_contains(strtolower($field->slug), $p));

Organisers can fine-tune after migration via form-builder UI.

11.3 Rollback

Each migration has down(). Rollback restores legacy columns (event_id, person_id, registration_form_field_id) from the new structure before renaming tables back.

11.4 Migration rehearsal (mandatory)

Before running on real data:

  1. Take a full dump of dev DB
  2. Run migration on a copy
  3. Run comparison script: per organisation, compare pre/post counts and sample 10 random submissions, assert identical rendering
  4. If any mismatch: stop, investigate
  5. Only after clean rehearsal run on main dev DB

12. Seeding strategy

12.1 System form templates (on org creation, idempotent)

Every new organisation gets these with is_system = true. Detailed field lists in §3.2 (per-purpose lifecycles).

  • EventRegistrationDefault (§3.2.1)
  • UserProfileDefault (§3.2.2)
  • ArtistAdvanceDefault (§3.2.5)
  • SupplierIntakeDefault (§3.2.6)
  • IncidentReportDefault (§3.2.7)
  • PostEventEvaluationDefault (§3.2.9)
  • SignatureContractDefault (§3.2.10)
  • SignatureCodeOfConductDefault (§3.2.11)
  • SignatureReceiptDefault (§3.2.12)
  • FeedbackDefault (§3.2.8)

System templates can be customised per org but not deleted; only deactivated.

12.2 System form_field_library entries (on org creation, idempotent)

Common reusable fields seeded per organisation:

  • Shirtmaat (SELECT, options: XS/S/M/L/XL/XXL, is_filterable=true)
  • Dieetwensen (CHECKBOX_LIST, options: vegetarisch/veganistisch/ glutenvrij/lactosevrij/halal/kosher, is_filterable=true)
  • Noodcontact naam (TEXT, is_pii=true)
  • Noodcontact telefoon (PHONE, is_pii=true)
  • Geboortedatum (DATE, is_pii=true)
  • Toestemming AVG (BOOLEAN, required, consent field)
  • Opmerkingen (TEXTAREA)

Organiser can insert these into any schema with one click.

12.3 System person_tags (on org creation, idempotent)

Certificates (category: Certificaat): EHBO, BHV, VCA-VOL, VCA-BASIS, SVH Sociale Hygiëne, Horeca-diploma, Keuringsregister, Rijbewijs A, Rijbewijs B, Rijbewijs BE, Rijbewijs C, Rijbewijs CE, Aanhangwagen-rijbewijs, Heftruckcertificaat, Hoogwerker-certificaat, AED-training.

Languages (category: Taal): Engels, Duits, Frans, Spaans, Italiaans, Arabisch, Turks, Pools, Portugees.

Skills (category: Vaardigheid): Tapper, Barista, Kassa-ervaring, Security-ervaring, Podium-ervaring, Techniek-ervaring, Logistiek-ervaring, Cateringervaring, Schoonmaak-ervaring, Verkeersregelaar, Parkeerplaats-ervaring.

Seeded via PersonTagSystemSeeder using updateOrCreate keyed on (organisation_id, name). Organisations can deactivate seeded tags; cannot delete (hidden flag is_system_seed = true).


13. Governance & compliance

13.1 PII-flagging

form_fields.is_pii = true marks a field as personal data. Effects:

  • Included in PII-audit endpoint: GET /organisations/{org}/forms/pii-fields
  • Treated as sensitive in exports (red border, extra confirmation)
  • Subject to retention_days when set
  • Displayed with PII-indicator icon in organiser UI

13.2 Retention policies

form_schemas.retention_days (nullable int). When set:

  • Scheduled job FormSubmissionRetentionJob runs daily at 03:00
  • Finds submissions where submitted_at < NOW() - retention_days AND is_test = false AND anonymised_at IS NULL
  • Calls FormSubmissionAnonymisationService (§13.3)
  • Sends aggregate notification email to org_admin at end of run: "{count} submissions anonymised today for organisation X"

13.3 Right-to-be-forgotten (anonymisation)

FormSubmissionAnonymisationService::anonymise(FormSubmission):

  • For each form_value of a submission:
    • If field.is_pii: set value='[ANONYMISED]', value_indexed=null, value_number/date/bool=null, value_anonymised=true
    • If signature field: delete signature file, replace value with {anonymised: true}
    • If file_upload field: delete file, replace value with {anonymised: true, original_filename_redacted: true}
  • Set submission.anonymised_at = NOW()
  • Clear submission.search_index
  • Log activity log entries per field (one entry per anonymised field)
  • Fire FormSubmissionAnonymised event

Called by:

  • Admin action via POST /form-submissions/{id}/anonymise (org_admin only)
  • Automatic retention job based on retention_days
  • GDPR user-initiated deletion request (see §13.4)

13.4 Right-to-be-forgotten workflow (GDPR)

When a user requests data deletion (§31.2 integration contract):

  1. All form_submissions where subject_type = 'user' AND subject_id = that_user are anonymised
  2. All form_submissions where submitted_by_user_id = that_user: submitted_by_user_id → null; name/email copied to public_submitter_* before clearing
  3. user_profiles row deleted (cascade via FK)
  4. user_organisation_tags for that user deleted
  5. Activity log entries for that user anonymised (causer replaced with "[deleted-user]" but action records preserved)

GDPR export endpoint (§31.2):

GET /api/v1/users/{user}/gdpr-export

Returns JSON with all user-owned data. Organiser-initiated via admin action, or user-initiated via portal profile page. Format: JSON (v1). PDF export deferred.

form_schemas.consent_version — when a submission is made, the schema's current consent_version is snapshotted into the submission's schema_snapshot (if snapshot_mode != 'never') OR logged in activity log at minimum. Organiser can demonstrate which consent text the submitter accepted.

13.6 Test submissions

form_submissions.is_test = true:

  • Excluded from all list/aggregate endpoints by default
  • Excluded from retention policies (never anonymised)
  • Excluded from export endpoints
  • Accessible only via explicit include_test=true query param
  • Created via "Test submission" button in form preview
  • Do not fire webhooks
  • Do not count toward max_submissions cap

Organiser can create test submissions without cluttering real data. Test submissions are purged manually via a "Clear test data" action.


14. Schema lifecycle & versioning

Three independent mechanisms; each with a distinct purpose.

14.1 form_schemas.version

Always active. Integer, starts at 1, bumped on any schema-structural edit (fields added/removed/reordered, required/optional changes, options changes). Bump triggered by FormSchemaService on save. Used for:

  • Detecting schema drift between current and submission-time state
  • Generating ETags for caching
  • Cache invalidation

Version bumps do NOT produce separate activity log entries when snapshot_mode=never — the field-level activity log entries already cover the actual change. When snapshot_mode=on_submit or always, the version bump is logged for cross-reference with stored snapshots.

14.2 form_schemas.snapshot_mode + form_submissions.schema_snapshot

Controls whether and when full schema snapshots are stored per submission:

  • never (default) — no snapshot; trust version number + audit log for history
  • on_submit — snapshot stored when submission transitions to status=submitted
  • always — snapshot stored on any save, including drafts

Used for high-stakes submissions (contracts, signature_receipt, post_event_evaluation for regulatory retention). Trade-off: more storage, but bulletproof audit trail.

Snapshot structure defined in §4.6.1.

14.3 form_schemas.freeze_on_submit

If true: once the schema has at least one submission with status=submitted, the schema becomes read-only at the field-structure level. Labels/help_text can still be edited (lightly); fields cannot be added, removed, or have their type changed. Bypass requires elevated permission and creates a loud audit log entry.

Complementary to version + snapshot, not overlapping: freeze_on_submit prevents edits; version tracks them; snapshot preserves history.

14.4 Test/preview submissions

form_submissions.is_test = true for:

  • Submissions created from form preview mode
  • Submissions created with an X-Crewli-Test-Mode: true header

See §13.6 for test submission semantics.

14.5 [v1.2] Edit-lock mechanism

form_schemas.edit_lock_user_id + edit_lock_expires_at implement pessimistic locking for collaborative editing:

  • When organiser A opens the form-builder in edit mode: acquire lock with edit_lock_user_id = A, edit_lock_expires_at = now() + 10 minutes
  • Heartbeat: every 2 minutes while edit is open, extend edit_lock_expires_at by another 10 minutes
  • When organiser B tries to open the same schema in edit mode:
    • If current lock expired: auto-release, B acquires
    • If current lock still valid: 409 Conflict with holder's name + expiration time
    • B can force-steal via explicit confirmation (creates audit log entry)
  • On save/close: release lock
  • On logout/connection loss: heartbeat stops, lock auto-expires

Frontend UX: lock banner shows "Schema wordt bewerkt door {naam} (nog {X} minuten)" with "Vraag toegang" button that triggers force-steal flow.

14.6 [v1.2] Form preview mode

Preview endpoint:

GET /api/v1/organisations/{org}/form-schemas/{schema}/preview

Returns the schema in render-ready format with sample field values that drive conditional_logic. No submission created; values are mocked per-field-type (TEXT = "Lorem ipsum", SELECT = first option, etc.).

Frontend renders preview in a side-panel or separate route without the normal submit flow (submit button disabled or replaced with "Dit is een voorbeeld").

Organiser can toggle "Test mode" in preview — triggers actual submission with is_test=true so they can verify the whole flow end-to-end.


15. Workflows & submission reviews

15.1 Simple review flow

Every submission supports review regardless of schema:

  • review_status on form_submissions: null / pending_review / approved / rejected / changes_requested
  • Set via POST /form-submissions/{id}/review with body { status, review_notes }
  • Fires FormSubmissionReviewed event
  • Activity log records reviewer, decision, notes

15.2 Section-level submit (for advancing)

When form_schemas.section_level_submit = true:

  • form_schema_sections define independent submit units
  • form_submission_section_statuses track per-section state
  • Each section has its own submit/review flow
  • Section dependencies (depends_on_section_id) gate later sections until earlier ones are approved

15.3 Delegation

For "Nina fills in my advance on my behalf":

  • Subject grants delegation via POST /form-submissions/{id}/delegate
  • Delegated user can view and edit that submission
  • Delegator retains full control and can revoke
  • Delegation audited via activity log

Note: delegation is per-submission, not per-schema. Organisers don't grant delegations — subjects do.

15.4 Multi-step gating

Via form_schema_sections.depends_on_section_id. A section is locked (read-only, clearly marked) until the dependency's section_status is 'approved'. Used for workflows like "finish general info first, then rider details unlock".

15.5 [v1.2] Review workflow notifications

On status change:

  • pending_review → notify org_admins (or event_managers if schema is event-scoped) via CrewliMailable
  • approved → notify submitter (if authenticated user) via CrewliMailable
  • rejected / changes_requested → notify submitter with review_notes in email

Notification flow is asynchronous via FormSubmissionReviewed event listener SendReviewNotificationListener. Recipients configurable per schema via form_schemas.settings.review_notification_recipients (array of user_ids or role names).


16. Internationalisation

16.1 Per-field translations

form_fields.translations JSON:

{
  "en": {
    "label": "T-shirt size",
    "help_text": "What size do you wear?",
    "options": ["XS", "S", "M", "L", "XL", "XXL"]
  },
  "de": {
    "label": "T-Shirt Größe",
    "help_text": "Welche Größe trägst du?",
    "options": ["XS", "S", "M", "L", "XL", "XXL"]
  }
}

If no translation exists for a locale, fallback applies (§16.2).

16.2 Locale fallback chain

FormLocaleResolver service determines the effective locale:

  1. submitter.locale (if submitter is authenticated)
  2. Explicit locale passed in request (via Accept-Language header)
  3. form_schemas.locale (schema's primary locale)
  4. App default locale (config('app.locale'))

Each field label/help_text/options resolved per-locale, with graceful fallback to default-locale values when translation is missing.

16.3 Timezone handling

DATETIME values are stored as UTC ISO-8601. Display-time conversion to submitter's timezone:

  1. users.timezone (if authenticated)
  2. organisation.timezone
  3. App default (config('app.timezone'))

submitted_in_locale on form_submissions stores the locale used at submit time for audit.

16.4 Runtime resolution deferred

For v1, translations column EXISTS but runtime locale resolution is minimal (schema.locale + fallback only). Full per-field runtime swap is deferred; v1 renders in the schema's primary locale with translations column available for future activation.


17. Extensibility & integrations (webhooks, custom field types, custom purposes)

17.1 Laravel events

Every submission state change fires a Laravel event. Registered in EventServiceProvider.

Submission lifecycle events:

  • FormSubmissionCreated(FormSubmission $submission) — on insert
  • FormSubmissionDraftUpdated(FormSubmission $submission, array $changedFields) — on draft save (rate-limited to once per 30s to prevent spam)
  • FormSubmissionSubmitted(FormSubmission $submission) — status → submitted
  • FormSubmissionReviewed(FormSubmission $submission, string $outcome) — review_status change
  • FormSubmissionAnonymised(FormSubmission $submission) — on anonymisation
  • FormSubmissionArchived(FormSubmission $submission) — status → archived
  • FormSubmissionDeleted(FormSubmission $submission) — soft-deleted
  • FormSubmissionSectionSubmitted(FormSubmission, FormSchemaSection) — section submit
  • FormSubmissionSectionReviewed(FormSubmission, FormSchemaSection, string $outcome) — section review

Schema lifecycle events (optional, activity log covers most):

  • FormSchemaPublished(FormSchema $schema)
  • FormSchemaUnpublished(FormSchema $schema)
  • FormSchemaTokenRotated(FormSchema $schema)

All events implement ShouldBroadcast so real-time UI updates are possible in future iterations.

17.2 Custom field types

CustomFieldTypeRegistry allows extensions without core code changes:

// config/form_builder.php
'custom_field_types' => [
    'rich_text' => \App\FormFieldTypes\RichTextFieldHandler::class,
    // ...
],

Each handler implements CustomFieldTypeHandler interface:

interface CustomFieldTypeHandler
{
    public function validateValue(mixed $value): ValidationResult;
    public function transformForStorage(mixed $value): array;
    public function transformForDisplay(array $storedValue): mixed;
    public function getUISchema(): array; // tells frontend how to render
    public function supportsFiltering(): bool;
    public function getValueStorageHint(): ?string;
}

FormSchemaService uses the registry when validating field_type on create/ update. Built-in types are registered by default. Custom types extend the list without code changes to core.

17.3 Purpose registry

The set of purposes served by the form builder is a closed, code-defined vocabulary. There is no "custom purpose" escape. Organisations cannot invent purposes at runtime. This is deliberate — purpose drives subject handling, submission mode, public-access rules, pre-publish required bindings, and downstream listeners; each of those needs explicit code support.

v1.0 vocabulary (seven purposes, defined in config/form_builder/purposes.php):

Slug Label (NL) Subject type Default submission mode Public access Required bindings
event_registration Aanmelding vrijwilligers/crew person single yes person.email, person.first_name, person.last_name
artist_advance Artiest advance artist draft_single no
supplier_intake Leverancier intake company single no company.name

allows_public_access is the schema-level public-submission flag. Portal-token-based flows (artists, suppliers, press) are a different mechanism and do not consume this flag.

PurposeDefinition value object (app/FormBuilder/Purposes/ PurposeDefinition.php) holds the five properties above plus the slug. It is immutable (final readonly) and subjectType uses the morph alias, not the FQCN.

PurposeRegistry service (app/FormBuilder/Purposes/ PurposeRegistry.php) reads the config file, memoises the parsed definitions per instance, and exposes:

  • all()array<slug, PurposeDefinition>
  • get(string $slug) — throws PurposeNotFoundException on miss
  • has(string $slug) — bool
  • allSubjectTypes() — sorted, unique list of subject-type aliases; consumed by AppServiceProvider::registerMorphMap() (domain-subject block) and by StoreFormSubmissionRequest validation
  • publicAccessibleSlugs() — slugs whose schemas permit public submission

MorphMapAlignmentTest guards the invariant that every subject_type returned by PurposeRegistry::allSubjectTypes() is registered as a key in Relation::morphMap().

Required-bindings pre-publish check. FormSchemaService::publish() fails with PurposeRequirementsNotMetException (structured; purposeSlug + missingBindings[]) if any binding path in the schema's PurposeDefinition::requiredBindings is not present on at least one field of the schema. The check queries the relational form_field_bindings table directly (§6.7) — it assembles the set of {target_entity}.{target_attribute} pairs across the schema's fields and diffs them against requiredBindings. External contract (purposeSlug + missingBindings[]) unchanged.

Adding a new purpose. In scope only via an architect-level decision:

  1. Add a migration if existing schemas carry the new slug via data.
  2. Add the new entry to config/form_builder/purposes.php.
  3. If new subject type: register the FQCN in AppServiceProvider::PURPOSE_SUBJECT_FQCN; MorphMapAlignmentTest enforces this step.
  4. Add listeners wired to FormSubmissionSubmitted as needed (identity-match, tag sync, entity creation, etc.).
  5. Add a lifecycle paragraph under §3.2 and a row to the table above.

17.4 Validation rules

Pre-WS-5b, validation rules lived as a flat JSON bag on form_fields.validation_rules and form_field_library.validation_rules. WS-5b moved them to the relational form_field_validation_rules table (one row per rule), in parallel with §6.7 (bindings). This section is the canonical home for the rule catalogue, the relational-table shape, the callback-registry integration, and the legacy-key migration notes.

17.4.1 Rule-type catalogue

FormFieldValidationRuleType (PHP backed enum) is the canonical list of rule_type values. Each case documents the required parameters shape that FormFieldValidationRuleService::replaceRules() enforces.

rule_type parameters shape Notes
min_length {"value": int} Minimum character length for string-valued fields
max_length {"value": int} Maximum character length
min_value {"value": number} Minimum numeric value for NUMBER fields
max_value {"value": number} Maximum numeric value
regex {"pattern": string, "flags": string?} preg_match pattern; flags optional
email_format {} Boolean marker — enforces RFC-5321 email shape
url_format {} Boolean marker — enforces URL shape
phone_e164 {} Boolean marker — enforces E.164 phone shape
allowed_mime_types {"mime_types": [string]} Whitelist for FILE_UPLOAD / IMAGE_UPLOAD / SIGNATURE
max_file_size {"bytes": int} Upper bound for uploads
min_selected {"value": int} Lower bound for multi-value selections
max_selected {"value": int} Upper bound; covers the legacy max_priorities key
date_min {"date": string} ISO-8601 date; lower bound for DATE / DATETIME
date_max {"date": string} Upper bound
callback {"key": string} Registered callback key, see §17.4.3

Not in the catalogue (deliberate):

  • requiredform_fields.is_required column is the single source of truth. Any legacy validation_rules.required JSON is WARN-logged and skipped at backfill.
  • uniqueform_fields.is_unique column is the single source of truth. The pre-WS-5b JSON fallback path in FormValueService was stripped in WS-5b commit 3.
  • tag_categories, storage_disk — not validation rules, they are field-rendering / upload-storage configuration. WS-5b relocates these to a separate form_field_configs table (ARCH §17.5, landed in WS-5b commit 5) rather than polluting the validation-rules catalogue.

Rule types are app-enforced, not DB enum. The column is string(40) so the enum can extend in application code without a migration — identical rationale to form_fields.field_type (§4.2) and CustomFieldTypeRegistry.

17.4.2 Relational table form_field_validation_rules

Columns (SCHEMA.md §3.5.12):

Column Type Notes
id ULID PK
owner_type string(40) morph alias: form_field or form_field_library
owner_id ULID parent row
rule_type string(40) enum case value
parameters JSON per-rule-type bag
error_message_key string(100) null optional i18n key for custom rejection copy
created_at, updated_at timestamps
  • Unique: (owner_type, owner_id, rule_type) — at most one rule of each type per field.
  • Indexes: (rule_type) for "which fields enforce regex?" queries, (owner_type, owner_id) for per-owner lookups.
  • Morph-map aliases form_field and form_field_library are reused from WS-5a (AppServiceProvider::registerMorphMap) — no new entries needed.

Multi-tenancy (FormFieldValidationRuleScope). Sibling to FormFieldBindingScope with identical UNION-over-two-owner-chains shape:

owner_id ∈ (
  SELECT id FROM form_fields
    WHERE form_schema_id ∈ (SELECT id FROM form_schemas WHERE organisation_id = ?)
  UNION
  SELECT id FROM form_field_library
    WHERE organisation_id = ?
)

Organisation context resolution mirrors OrganisationScope; the escape hatch is FormFieldValidationRule::withoutGlobalScope(FormFieldValidationRuleScope::class).

FormFieldValidationRuleScope is a marker subclass of FormFieldChildTableMorphScope; the shared logic lives in the abstract base. See app/Models/Scopes/FormFieldChildTableMorphScope.php. Identity-preserving extraction landed after WS-5d — rationale and Phase A diff verification in ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §"Uitvoering — base scope-class extractie".

Service boundary (FormFieldValidationRuleService). All writes go through the service — no controller writes rules directly on the model. The service owns:

  • rulesFor(owner) — eager, scope-aware fetch.
  • replaceRules(owner, specs) — transactional delete + insert; validates every spec's rule_type against the enum and parameters against the per-rule shape (including callback-key registry check). Logs field.validation_rules_replaced on the owning FormField subject (matching the WS-5a convention: library-level changes are silent in activity log).
  • copyRules(library, field) — row-clone on FormFieldService::insertFromLibrary (addendum Q3 row-copy mandate).
  • toJsonShape(rules) — single source of truth for serialising a collection to the canonical flat bag shape consumed by the snapshot writer (FormSubmissionService::buildSnapshot) and by API resources (FormFieldResource, FormFieldLibraryResource, PublicFormSchemaResource).
  • assertSpecsValid(specs) — public helper the FormRequests invoke in their after() hook to reject bad specs at the HTTP boundary before any write lands (strict validator on save, WS-5b commit 3).

Cascade (FormFieldChildTablesCascadeObserver). Shared observer — also cleans up form_field_bindings rows (WS-5a) and form_field_configs rows (WS-5b commit 5). Rules are physical state, not audit: on soft- or hard-delete of the owner, the observer physically deletes the rows.

Activity log events. Changing a field's rules emits two entries on the parent FormField subject:

  • field.updated — payload includes old.validation_rules / new.validation_rules shapes reconstructed from the relational table via FormFieldValidationRuleService::toJsonShape(). Preserves the pre-WS-5b audit-consumer contract for downstream tooling that parses field.updated diffs.
  • field.validation_rules_replaced — the semantic rule-change event, emitted by FormFieldValidationRuleService::replaceRules().

Both fire for the same semantic change. Aggregate queries over activity-log event counts should filter on one, not both — the WS-5a precedent (§6.7).

17.4.3 Callback rules

rule_type = callback references a named handler registered in config/form_builder.php:

'validation_callbacks' => [
    'kvk_lookup' => \App\Services\Validators\KvkValidator::class,
    // ...
],

The rule row's parameters.key must be a key in this map — unregistered keys are rejected by FormFieldValidationRuleService::assertSpecsValid() at save time (WS-5b commit 3 strict validator).

Legacy FQCN@method strings are not accepted. Pre-WS-5b validation_rules JSON sometimes stored callbacks as fully-qualified class strings (App\Services\Validators\KvkValidator@validate). Those were never a formal contract and are not portable across refactors. Backfill surfaces them as WARN log lines for manual review; operators either register the callback under a named key in config/form_builder.php or drop the reference.

17.4.4 Legacy JSON migration (WS-5b)

The WS-5b backfill migration (2026_04_25_110001_backfill_form_field_validation_rules.php) translates pre-WS-5b JSON keys to relational rows. Strict-enterprise dispatch — no guessing, no silent drops for unknown data.

  • Column-duplicates (required, unique): WARN-log and skip. The is_* columns are the single sources of truth.
  • Canonicalisations: legacy max_priorities (SECTION_PRIORITY UI soft cap) collapses to rule_type = max_selected — same semantic of "cap on entries in a list-valued field", and two enum cases for one semantic is rot.
  • Ambiguous min / max: dispatched by field_type:
    • NUMBER → min_value / max_value
    • TEXT/TEXTAREA/EMAIL/PHONE/URL → min_length / max_length
    • DATE/DATETIME → date_min / date_max
    • anything else → FAIL the migration. Type-inappropriate uses of min / max are seed-data bugs.
  • Non-validation keys (tag_categories, storage_disk): skipped with an INFO log line — commit 5's configs-backfill migration picks them up into the separate form_field_configs table (ARCH §17.5).
  • Unknown top-level keys: FAIL the migration. Phase A seed-scan should have caught these; if one slips through we want the crash, not the skip.

Rollback reconstructs the JSON bag using canonical keys (post-rename). It does NOT resurrect column-duplicates or non-validation keys — those never landed in the relational table. The forward+back pair is safe as a unit; a partial rollback that pops this migration but leaves its create-table sibling is not a supported state.

Historical snapshots written pre-WS-5b embed the legacy flat bag (validation_rules: {"min": 16, "max": 99, "max_priorities": 3}). Those rows are immutable records and are not rewritten by the migration. Snapshot readers must tolerate both shapes — pre-WS-5b legacy keys and post-WS-5b canonical keys.

17.5 Field configuration (non-validation)

Per-field configuration that is not validation (tag-picker category filters, upload disk selection) lives in the relational form_field_configs table — a deliberate sibling to §17.4's form_field_validation_rules, not a merger. Two tables with clear semantics beat one table that drifts into "bucket for everything that doesn't fit elsewhere".

17.5.1 Why this is separate from validation_rules

Pre-WS-5b, form_fields.validation_rules was a grab-bag that held validation and non-validation keys. Keeping the non-validation keys in a table named form_field_validation_rules would have poisoned that table's meaning and re-introduced the drift WS-5 was cleaning up. The strict-enterprise resolution on the Q3 WS-5b decision gate was: split the non-validation keys into their own relational home with matching semantics ("table name = table contents"), at the cost of one extra table, one extra enum, one extra service, one extra scope. The architecture decision log is in /dev-docs/ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §Q3 WS-5b Uitvoering.

17.5.2 Table form_field_configs and config-type catalogue

Columns (SCHEMA.md §3.5.12):

Column Type Notes
id ULID PK
owner_type string(40) morph alias: form_field or form_field_library
owner_id ULID parent row
config_type string(40) enum case value
parameters JSON per-config-type bag
created_at, updated_at timestamps

Catalogue (FormFieldConfigType):

config_type parameters shape Consumed by
tag_categories {"categories": [string]} FormFieldResource + PublicFormSchemaResource — filters person_tags options for TAG_PICKER fields
storage_disk {"disk": string} FormValueService (file-upload handling — WS-6) — overrides the default filesystem disk

Both config types are app-enforced, not DB enum — same rationale as §17.4.1 (runtime extensibility via registry).

17.5.3 Service, scope, cascade, activity log

Mirrors §17.4's validation-rules stack one-for-one:

  • Service boundary (FormFieldConfigService) — configsFor, replaceConfigs, copyConfigs, toJsonShape, assertSpecsValid. Single writer; all controller paths go through it.
  • Multi-tenancy (FormFieldConfigScope) — marker subclass of FormFieldChildTableMorphScope; the shared UNION-over-two-owner- chains logic lives in the abstract base. See app/Models/Scopes/FormFieldChildTableMorphScope.php. Identity- preserving extraction landed after WS-5d (rationale in ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §"Uitvoering — base scope-class extractie").
  • Cascade — shared FormFieldChildTablesCascadeObserver (renamed from FormFieldBindingsCascadeObserver in WS-5b commit 1) covers all three relational tables on owner delete.
  • Activity log — two entries emit on config changes on a FormField subject: field.updated (reconstructed configs via toJsonShape) and field.configs_replaced (semantic event). Matches the §6.7 / §17.4.2 pattern. Library-level changes are silent in activity log; consumers that need them listen at a different layer.

17.5.4 Snapshot embedding

form_submissions.schema_snapshot.fields[*] gains a top-level configs key alongside validation_rules:

{
  "id": "01H...",
  "slug": "vaardigheden",
  "field_type": "TAG_PICKER",
  "validation_rules": null,
  "configs": { "tag_categories": { "categories": ["Veiligheid"] } },
  ...
}

Historical snapshots written before WS-5b commit 5 continue to embed the merged shape (validation_rules: {"tag_categories": [...], "min": 3}) with no configs key — those rows are immutable records. Readers must tolerate both shapes.

17.5.5 External API contract change

WS-5b commit 5 is a breaking change to the form-field JSON contract. Pre-WS-5b: field.validation_rules.tag_categories. Post-WS-5b: field.configs.tag_categories.categories. Same for storage_disk. The portal + organizer SPAs are updated in the same work package (WS-5b commit 5); there is no bridging compatibility layer. See the "Breaking change acceptance" note at the top of this document.


17.6 Field options (relational)

17.6.1 Rationale

Pre-WS-5d, form_fields.options and form_field_library.options were JSON columns of flat string arrays consumed by RADIO / SELECT / MULTISELECT / CHECKBOX_LIST. The shape conflated three distinct concerns: the canonical storage value, the default-locale display label, and per-locale translations (which lived elsewhere as the parallel translations.{locale}.options[] indexed array). WS-5d splits the bag into one polymorphic relational table where each row is a single option carrying value, label, sort_order and an optional per-locale translations JSON map.

WS-5d follows the WS-5a / WS-5b discipline one-for-one: dedicated service as single writer, UNION-over-two-owner-chains scope, shared cascade observer. Fourth and final WS-5 sibling — landing it materialised the four concrete morph-scope implementations. The follow-up base-class extraction landed in a separate work package post-WS-5d (FormFieldChildTableMorphScope).

17.6.2 Table + catalogue

Single table form_field_options carrying:

  • value — string ≤255 chars, the canonical storage value used by the in:options validator and embedded in form_values rows. UNIQUE per owner.
  • label — string ≤255 chars, the default-locale display label.
  • sort_order — int unsigned, stable ordering within owner.
  • translations — JSON nullable, {<locale>: <translated label>} with BCP-47 short-form locale keys (nl, en, nl_BE, en_GB).

Polymorphic owner: morph aliases form_field and form_field_library, reused from WS-5a. UNIQUE index ffo_owner_value_unique on (owner_type, owner_id, value) is the seed-bug guard — duplicate values per field have no semantic meaning and must fail at both the service layer (assertSpecsValid) and the DB level. Sort-order index ffo_owner_sort_idx on (owner_type, owner_id, sort_order) for ordered fetches.

Applies only to field types that consume options: RADIO / SELECT / MULTISELECT / CHECKBOX_LIST. TAG_PICKER's category filter lives in form_field_configs (§17.5); AVAILABILITY_PICKER and SECTION_PRIORITY source options dynamically from sibling endpoints. Any other field type carrying non-null options in pre-WS-5d data is a seed bug and the strict-fail backfill rejects it.

17.6.3 Service / scope / cascade / activity log

FormFieldOptionService is the single writer. Public surface:

  • optionsFor(owner) — eager, ordered by sort_order
  • replaceOptions(owner, specs) — transactional: validate spec list, delete prior rows, insert new rows. Returns the fresh collection.
  • copyOptions(from, to) — pure row-clone for FormFieldService::insertFromLibrary per the addendum Q3 row-copy mandate. No activity-log emit (the wrapping field-creation event carries the audit).
  • toJsonShape(collection) — serialises to the rich-shape array used by snapshot writer, API resources and FilterRegistryController.
  • assertSpecsValid(specs) — public spec-shape gate, used by FormRequests in their after() hook to reject malformed specs at the HTTP boundary before any write.

FormFieldOptionScope is a marker subclass of FormFieldChildTableMorphScope; the shared UNION-over-two-owner- chains logic lives in the abstract base. See app/Models/Scopes/FormFieldChildTableMorphScope.php. The "what actually varies" question across the four siblings was answered empirically (Phase A diff verification clean: nothing varies). See ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md §"Uitvoering — base scope-class extractie".

FormFieldChildTablesCascadeObserver extended to physically delete option rows on owner soft-delete OR force-delete; options are physical state, not audit (submission snapshots carry the historical shape).

Activity log dual-emit on FormField subject only (mirrors §6.7 / §17.4.2):

  • field.updated carries old.options / new.options diff via toJsonShape() reconstruction. The diff is byte-equal JSON-compared to skip cosmetic false positives — bare label/sort_order updates that don't touch options omit the key entirely.
  • field.options_replaced is the semantic event from replaceOptions(), payload {options: [...rich shape...]}.

Library-subject writes are silent in activity log (consistent with WS-5a / WS-5b convention; library audits live elsewhere).

17.6.4 Snapshot embedding

FormSubmissionService::buildSnapshot walks fields[*] and emits options through FormFieldOptionService::toJsonShape() in the same rich shape exposed by API resources. The pre-WS-5d translations.{locale}.options[] parallel arrays are dead — option translations live on each option row's own translations JSON. The field-snapshot's translations bag retains only {label, help_text} per locale. WS-5d commit 2's backfill rewrote every existing submission + template snapshot in-place; no historical flat-array options remain post-commit-2.

17.6.5 External API contract (no bridging)

Resources, snapshot writer, and FilterRegistryController emit options uniformly as the rich shape:

[
  {"value": "red",   "label": "Red",   "sort_order": 0,
   "translations": {"nl": "Rood"}},
  {"value": "green", "label": "Green", "sort_order": 1}
]

Empty option set serialises as null (preserves the option-less field-type contract). Per ARCH-FORM-BUILDER §0 "Breaking change acceptance", the portal SPA was migrated atomically in WS-5d commit 4 with no flat-array carve-out. Downstream consumers wanting the raw value list extract options.map(o => o.value); consumers wanting Vuetify-style {value, title} pairs use resolveOptionLabel(option, locale) from @form-schema/types/formBuilder and map over.


17.7 Webhooks

17.7.1 Schema

See §4.11 form_schema_webhooks and §4.12 form_webhook_deliveries.

17.7.2 Dispatcher

FormWebhookDispatcher listens for FormSubmissionSubmitted / Reviewed / SectionSubmitted / SectionReviewed events. On trigger:

  • Finds matching webhooks for the schema
  • For each: creates a form_webhook_delivery row with status=pending
  • Queues DeliverFormWebhookJob per delivery on dedicated webhooks queue

17.7.3 Delivery job

DeliverFormWebhookJob on webhooks queue:

  • Idempotent (Laravel job with unique ID per delivery)
  • Timeout: 30s per attempt
  • On execution:
    • Builds payload (submission data + trigger_event + schema metadata)
    • Signs with HMAC-SHA256 if webhook.secret is set, header: X-Crewli-Signature: sha256=...
    • POSTs to webhook.url with 10-second HTTP timeout
    • On 2xx: updates delivery status=delivered, delivered_at=now()
    • On retriable (5xx, timeout, network): exponential backoff (1m, 5m, 30m, 2h, 8h). Max 5 attempts.
    • On non-retriable (4xx except 408/429): delivery status=failed, logged
    • After all retries exhausted: status=dead_letter

Response body first 1000 chars stored in response_body_excerpt for debugging.

17.7.4 Security

URL validation in FormWebhookDispatcher:

  • Parse URL; reject non-http(s)
  • Resolve host; reject private IP ranges (10.x, 172.1631.x, 192.168.x, 127.x, 169.254.x — AWS metadata)
  • Check allowlist if configured in config/form_builder.php.webhooks.allowlist_domains
  • Check blocklist; configurable IP ranges default to private + metadata

Admin UI shows validation status + last delivery attempt per webhook.

17.7.5 Webhook payload format

{
  "event": "form_submission.submitted",
  "triggered_at": "2026-04-17T14:23:11Z",
  "organisation": {
    "id": "01H...",
    "name": "...",
    "slug": "..."
  },
  "schema": {
    "id": "01H...",
    "purpose": "event_registration",
    "slug": "...",
    "version": 3
  },
  "submission": {
    "id": "01H...",
    "subject_type": "person",
    "subject_id": "01H...",
    "submitted_at": "2026-04-17T14:23:11Z",
    "submitted_by_user_id": null,
    "values": {
      "shirtmaat": "M",
      "dieetwensen": ["vegetarisch"],
      ...
    }
  }
}

PII fields are included in webhook payloads only if subscriber has been acknowledged (future feature: per-webhook PII opt-in). For v1, PII is sent — organisers warned during webhook creation.


18. Consistency & interaction rules

This section prevents components built in isolation from conflicting.

18.1 Schema evolution mechanisms

Three orthogonal mechanisms; never use them interchangeably:

  • schema_version = MARKS edits (always active)
  • schema_snapshot = BACKS UP state (per schema's snapshot_mode)
  • freeze_on_submit = PREVENTS edits (after first submission)

Pairing guidance:

  • Low-stakes forms (event_registration): snapshot_mode=never, freeze_on_submit=false
  • Audited forms (incident_report): snapshot_mode=on_submit, freeze_on_submit=true
  • Legal/contract forms (signature_contract): snapshot_mode=on_submit, freeze_on_submit=true

18.2 Hergebruik mechanisms

Three forms of reuse; distinct roles:

  • form_templates = COMPLETE starting point for new schemas (apply → new schema with copied fields)
  • form_field_library = SINGLE reusable field definition (insert → new form_field with reference)
  • schema_snapshot = FROZEN copy for audit (auto-generated, not reused)

A template can reference library fields in its schema_snapshot; when the template is applied, library fields are resolved to current library definitions at that moment (denormalised into the resulting form_fields).

18.3 Role restrictions vs Pattern C interaction

Rule: role_restrictions gate what OTHER users see. A subject always sees their own entity-owned or mirrored values. Example: a volunteer sees their own emergency_contact_phone even if role_restrictions hide it from organiser_members.

Validated by FieldAccessService: canRead(user, field, submission) returns true when user.id == submission.subject_id (and subject_type is 'user' or submission.subject is user's person) regardless of role_restrictions.

18.4 Auto-save vs is_test vs status

  • Auto-save always produces submissions with is_test=false and status='draft'
  • Test mode (form preview) produces submissions with is_test=true — regardless of auto-save setting
  • Status='draft' becomes 'submitted' only on explicit user submit, never on auto-save

18.5 Events as backbone

Webhooks, activity log, analytics, emails, and all integrations attach as LISTENERS on Laravel events. Never fire webhooks or log activity ad-hoc inside services. Rule: if your service needs to trigger a side effect on submission state change, add a listener to the relevant event. This keeps services testable and the event graph discoverable.

18.6 System-internal vs form-rendered reads

Services that consume profile/person/artist data for internal purposes (e.g., computing reliability_score, matching identity, sending briefings) read DIRECTLY from entity tables (user_profiles, persons, etc.). They do NOT go through form_fields/form_values.

The form layer is ONLY for:

  • Rendering forms to end users (reading bindings to prefill)
  • Accepting submissions (writing bindings back)
  • Displaying submissions to organisers (rendering stored form_values)

This rule is enforced by architecture reviews.


19. Self-hosting principle ("eat your own dog food")

Wherever possible, internal Crewli forms are expressed through the form builder itself. This is a test of universality: if our builder can't produce our own configuration UIs, it's not universal enough.

Targets in v1:

  • Onboarding wizard (FormPurpose::ONBOARDING_WIZARD) and event_setup_wizard ARE self-hosted from day one — they exist in the enum and are a test of universality.

20. Trade-offs & cost awareness

20.1 Performance contract

Pattern A lookups MUST be as fast as current direct-column lookups. The form layer is NOT a performance tax on system-internal reads — see §18.6. Pattern A bindings are documented, but services reading them consume the entity table directly.

20.2 Testing strategy

The FormPurpose × pattern × field-type matrix is combinatorially large. Pragmatic coverage:

  • Integration tests per FormPurpose: happy path for each of the 22 purposes, minimum one test each
  • Unit tests per field type: value transformation on read/write
  • Security invariant tests: admin_only fields never leak, role_restrictions enforced, SSRF blocked, binding-change-safeguard triggers
  • Migration rehearsal test: fixture-based test replicating the §11.2 script on a seeded dataset
  • Integration contract tests: per §31 contract, verify forms-to-other- module handoffs work

Target: 95%+ coverage on FormSchemaService, FormSubmissionService, FormValueService, FieldAccessService, FormLocaleResolver, FormWebhookDispatcher.

20.3 Developer onboarding

A new developer should be able to:

  • Understand "what is a form_schema" in under 10 minutes (TL;DR section)
  • Create a new FormPurpose and wire up a schema in under 1 hour (with template)
  • Add a custom field_type in under 4 hours (with CustomFieldTypeHandler example)

Quality gate: during implementation, write a /dev-docs/form-builder-getting-started.md with code examples per common scenario (new schema, new field type, new webhook, new subject type).

20.4 Migration risk

Mandatory rehearsal (§11.4) on dev DB copy before real migration. Comparison script output is part of the migration approval gate.


21. Pre-flight audit gate

Every Claude Code session starts with this audit, BEFORE any code changes:

# 1. Migration files
find api/database/migrations -name "*volunteer_profile*" -o -name "*user_profile*"

# 2. Model classes
find api/app/Models -name "VolunteerProfile*" -o -name "UserProfile*"

# 3. Resources
find api/app/Http/Resources -name "VolunteerProfile*" -o -name "UserProfile*"

# 4. References
grep -rn "volunteer_profile\|VolunteerProfile\|user_profile\|UserProfile" \
  api/app/ api/database/ --include="*.php"

# 5. Legacy column leakage
grep -rn "access_requirements\|tshirt_size\|first_aid\|driving_licence" \
  api/database/migrations/

# 6. Git history
git log --oneline --all | grep -iE "volunteer.profile|user.profile|volunteer_profiles"

# 7. Working tree
git status
git diff

Report findings. Nothing found → proceed. Anything found → STOP, report, wait for Bert's approval of proposed revert plan.

Audit is MANDATORY until the refactor completes and ARCH-FORM-BUILDER.md is marked as "refactor complete". After that, the audit is removed from session prompts.


22. Failure-mode resilience

22.1 Idempotency

Client-generated idempotency_key (ULID) in submission submit requests. Backend: UNIQUE(form_schema_id, idempotency_key) partial where not null. Duplicate request with same key is a no-op returning the original submission.

22.2 Auto-save

When form_schemas.auto_save_enabled=true:

  • Frontend debounces input at 2s; sends PUT on the draft submission
  • Backend endpoint PUT /form-submissions/{id}/auto-save accepts partial payloads (only changed fields)
  • Increments auto_save_count for debugging
  • Does not fire FormSubmissionDraftUpdated event on every auto-save (rate-limited to one event per 30s per submission) to prevent webhook storms
  • On auto-save failure: client retains local-storage backup and retries. Max 3 retry attempts with exponential backoff, then UI shows "Kon niet opslaan — probeer handmatig" warning.

22.3 Binding-change safeguard

See §6.5.

22.4 Audit trail

  • form_schemas.created_by_user_id, last_updated_by_user_id
  • Activity log on form_schemas, form_fields, form_submissions
  • Webhook deliveries logged with payload snapshots

22.5 Webhook security

See §17.5.4. SSRF prevention, allowlist/blocklist, timeouts.

22.6 File upload security

For FILE_UPLOAD / IMAGE_UPLOAD field types:

  • Server-side MIME type validation (never trust client)
  • Default allowed MIMEs in config/form_builder.php.file_uploads.default_allowed_mime_types
  • Per-field override via validation_rules.allowed_mime_types
  • Size cap default 5MB, per-field override via validation_rules.max_size_mb
  • Stored under {storage_disk}/form-uploads/{submission_id}/{ulid}.{ext}
  • Filename sanitised; original name stored as metadata only

Storage disk: config('filesystems.default') by default. Per-field override: validation_rules.storage_disk.

22.7 Soft limits

config/form_builder.php:

return [
    'limits' => [
        'max_fields_per_schema' => 100,
        'max_filterable_fields_per_schema' => 20,
        'max_options_per_field' => 100,
        'max_submissions_per_public_schema_per_ip_per_hour' => 5,
    ],
    'webhooks' => [
        'allowlist_domains' => [],
        'blocklist_ips' => ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12',
                             '192.168.0.0/16', '169.254.169.254/32'],
        'timeout_seconds' => 10,
        'max_attempts' => 5,
    ],
    'file_uploads' => [
        'default_allowed_mime_types' => ['image/jpeg', 'image/png', 'image/webp',
                                          'application/pdf'],
        'default_max_size_mb' => 5,
    ],
    'search_index' => [
        'max_chars' => 10000,
    ],
    'captcha' => [
        'provider' => 'turnstile',
        'required_for_purposes' => ['public_complaint', 'public_press_request'],
    ],
    'public_submitter_ip_retention_days' => 30,
    'user_profile_settings_whitelist' => [
        'ui.theme', 'ui.sidebar_collapsed', 'ui.time_format',
        'notifications.email_digest', 'notifications.shift_reminders',
        'notifications.event_updates',
    ],
    'custom_field_types' => [],
    'validation_callbacks' => [],
];

Violations rejected at Form Request validation layer with clear error messages.

22.8 Destructive operations

Deleting a schema with submissions requires typed confirmation (organiser types the schema name). Deleting a field with values requires same. Confirmation flow implemented in frontend; backend accepts ?confirmed_name=<n> query param.

22.9 Admin-only field leak prevention

Test class FormResourceSecurityTest covers:

  • For each FormPurpose, a submission is fetched by non-admin user
  • Assert admin_only fields are not in the response
  • Applied to both list and detail endpoints
  • Run as part of CI

22.10 [v1.2] Queue configuration

Dedicated queues for form-builder jobs:

// config/queue.php — add
'connections' => [
    'webhooks' => [
        'driver' => 'redis',
        'queue' => 'webhooks',
        'retry_after' => 120,
        'block_for' => null,
    ],
],

Jobs and their queues:

  • DeliverFormWebhookJobwebhooks queue (dedicated, can be throttled separately)
  • FormSubmissionRetentionJobdefault queue (daily schedule)
  • BackfillFormValueIndexedJobdefault queue (on is_filterable toggle)
  • RebuildSearchIndexJobdefault queue (on is_filterable or binding change)

Frontend event broadcasts (Reverb/Pusher) use their own broadcast queue.


23. Observability & analytics hooks

23.1 Timestamps for funnel analytics

  • opened_at — first GET on the form (or first frontend render)
  • first_interacted_at — first field interaction (focus or input)
  • submitted_at — status → submitted
  • submission_duration_seconds = submitted_at - opened_at (populated on submit)

form_submissions.search_index populated by an observer on form_values save. Concatenates all text-type values of a submission (TEXT, TEXTAREA, EMAIL, PHONE, URL, SELECT, RADIO option labels) into a space-separated text field. Indexed with MySQL FULLTEXT.

Max chars configurable (default 10000). Truncated with ellipsis.

For drafts: rebuilt on every form_values save. For submissions: frozen at submit (no further updates unless value changes).

23.3 Activity log depth

  • form_schemas: create, update (label/field additions/removals), delete, duplicate, version bumps, lock/unlock
  • form_fields: create, update (with old/new binding, old/new is_filterable, old/new role_restrictions), delete
  • form_submissions: create, status changes, review changes, delegate, anonymise, delete
  • form_templates: create, update, activate/deactivate
  • form_field_library: create, update, activate/deactivate

Activity log entries for webhook deliveries NOT added (they have their own audit table form_webhook_deliveries).

23.4 [v1.2] Anonymisation audit

Each anonymised field produces a SEPARATE activity log entry:

{
  "log_name": "form_value",
  "description": "field.anonymised",
  "subject_type": "FormValue",
  "subject_id": 12345,
  "properties": {
    "field_slug": "emergency_contact_phone",
    "reason": "retention_policy",
    "original_was_pii": true
  },
  "causer_type": "System",
  "causer_id": null
}

Rationale: field-level audit dichtheid matters for GDPR compliance demos.


24. Access control per field

24.1 form_fields.role_restrictions JSON

{
  "read": {
    "any_of_roles": ["org_admin", "event_manager"]
  },
  "write": {
    "any_of_roles": ["org_admin"]
  }
}

Operators on roles:

  • any_of_roles: user needs at least one listed role
  • all_of_roles: user needs all listed roles
  • not_roles: user must not have any listed role
  • subject_self: true → the subject themselves always has access (overrides other rules)

If role_restrictions is null: defaults to { read: true, write: { any_of_roles: ["org_admin", "event_manager"] } } for is_admin_only=false fields, and stricter for is_admin_only=true.

24.2 FieldAccessService

class FieldAccessService
{
    public function canRead(User $user, FormField $field, FormSubmission $submission): bool;
    public function canWrite(User $user, FormField $field, FormSubmission $submission): bool;
    public function filterVisibleFields(User $user, Collection $fields, FormSubmission $submission): Collection;
}

Used by:

  • FormResource / FormSubmissionResource when rendering API responses (hides invisible fields)
  • FormValueService when accepting submits (rejects writes to fields user can't write)
  • FilterQueryBuilder when applying filters (rejects filters on invisible fields with 403)
  • Form-builder UI (hides disallowed actions)

24.3 Test coverage

FieldAccessServiceTest and FormResourceSecurityTest ensure:

  • Per role, correct fields visible/editable
  • Subject-self always has access
  • Cross-org attempts blocked
  • role_restrictions=null defaults applied consistently

25. Self-hosting reservations (FormPurpose slots)

FormPurpose enum values reserved for future self-hosting (not implemented in v1.2):

  • schema_editor — would host the "create/edit form_schema" form as a form itself
  • field_editor — would host the "create/edit form_field" form as a form

When these become implemented, they demonstrate §19 (self-hosting) by making the builder's own UI a form defined through the builder.


26. Open questions / deferred decisions

  • Analytics dashboards — drop-off detection, A/B testing, completion rate dashboards. Telemetry client-side required. Deferred.
  • Marketplace templates — sharing templates between orgs. Deferred.
  • AI-generated schemas — LLM scaffolding of form definitions. Deferred.
  • Org-specific bindable columns — orgs adding custom columns on user_profile and binding to them. Needs a schema-extension subsystem. Deferred.
  • Bulk-edit values — "set dietary_preference=null for all persons". UX-heavy, needs audit. Deferred.
  • Schema-editor self-hosting — see §25. Deferred to a future phase.
  • Realtime collaborative editing — multiple editors with live cursors. Current v1.2 uses pessimistic locks (§14.5 edit_lock_*). Deferred to later phase with CRDT.
  • PDF export of submissions — deferred to PDF module.
  • Print layouts — deferred to PDF module.
  • Per-webhook PII opt-out — webhooks always receive PII in v1. Deferred.

27. Out of scope for this refactor

  • Workflow / approval escalation chains beyond the simple review flow
  • Notifications on submit/update beyond core integrations (advanced notification preferences UI)
  • PDF export of submissions (PDF module)
  • Rich-text TipTap-based fields (TIPTAP field type comes in a later phase)
  • Client-side field-by-field drop-off analytics
  • A/B testing of schema variants
  • Email bounce verification for public submissions
  • Legal signature-verification service (exists as stub in v1.2; UI deferred)

28. [v1.2] User guidance principles

This section defines the non-negotiable UX standards for form-builder features. No feature ships without complying with all five principles.

28.1 Every decision-impacting control has contextual help

Controls that change system behaviour in non-obvious ways must have an icon-triggered tooltip or inline explanation. Examples:

  • is_filterable toggle — tooltip: "Dit veld wordt extra geïndexeerd voor snelle filtering in overzichten. Alleen aanvinken voor velden die je daadwerkelijk als filter gebruikt."
  • is_pii toggle — tooltip: "Dit veld bevat persoonsgegevens. Bij retentie-verwerking worden deze waardes geanonimiseerd."
  • freeze_on_submit toggle — tooltip: "Na de eerste ingediende submissie kunnen de velden niet meer gewijzigd worden. Gebruik dit voor contracten of audit-kritieke formulieren."
  • snapshot_mode dropdown — per optie een korte uitleg in de dropdown zelf.
  • retention_days input — tooltip: "Na deze periode worden PII-velden geanonimiseerd. Vraag bij twijfel je privacy-officer om advies."

Rule of thumb: if an organiser has to guess what a toggle does, it's a bug in the UX.

28.2 Every destructive action has a preview or confirmation

Actions that cannot be undone (or are painful to undo) require either:

  • A preview showing consequences before the action, OR
  • A typed-confirmation dialog (user types the item name)

Destructive actions in scope:

  • Delete schema with submissions → typed confirmation
  • Delete field with values → typed confirmation
  • Change field's binding when submissions exist → preview showing "X submissions will be orphaned" + typed confirmation
  • Rotate public_token → preview showing grace period
  • Anonymise submission manually → typed confirmation
  • Change field_type (changes storage format) → preview + typed confirmation

Preview structure:

  1. What will happen (list)
  2. How many records affected
  3. Whether action is reversible (and how)
  4. Typed confirmation input if irreversible

28.3 Schema preview before publishing

Organiser can preview a schema at any time:

  • As each supported user role (volunteer, organiser admin, etc.)
  • In each locale (if translations exist)
  • With sample data driving conditional_logic
  • On mobile + desktop viewport

Preview button is prominently visible in form-builder UI, not hidden in a menu. Published schemas show a "Bekijk live formulier" link in the same place.

28.4 Onboarding for new features

Any new major feature (e.g., webhooks, section-level submit, custom field types) includes onboarding:

  • First-time dialog when the feature is accessed, explaining it briefly
  • Link to full VitePress documentation
  • Dismissible with "Niet meer tonen" checkbox — preference stored in user_profiles.settings

Every complex screen has a link to the relevant VitePress documentation:

  • Form-builder UI → /docs/organizer/forms/form-builder-overview
  • Webhook configuration → /docs/organizer/forms/webhooks
  • Retention policies → /docs/organizer/forms/privacy-and-retention
  • Field library → /docs/organizer/forms/reusable-fields
  • Section-level submit → /docs/organizer/forms/advancing-forms

Links open in new tab, marked with external-link icon.


29. [v1.2] Documentation coverage requirements per session

Every S1-S6 session MUST ship VitePress documentation alongside the code. Documentation is not an afterthought; it is part of Definition of Done.

29.1 Per-session docs requirement

Each implementation session delivers documentation covering:

  • User-facing feature — what the feature is, in organiser's language
  • How-to guide — step-by-step for the most common use-case
  • Reference — every configuration option explained
  • Edge cases — what happens in error conditions

Session cannot be marked complete until corresponding docs are reviewed and committed.

29.2 Documentation targets per session

Mapping of ARCH sections to docs pages (written during S1-S6):

ARCH section VitePress page Session
§3 FormPurpose /docs/organizer/forms/what-is-a-form-purpose S3
§4 Core tables (developer-only, /dev-docs/form-builder-getting-started.md) S1
§5 FormFieldType /docs/organizer/forms/field-types S3
§6 Field binding /docs/organizer/forms/binding-and-entity-columns S3
§7 Filter architecture /docs/organizer/forms/filtering-submissions S4
§8 Conditional logic /docs/organizer/forms/conditional-logic S3
§9 Signature /docs/organizer/forms/signatures-and-contracts S3
§10 Public tokens /docs/organizer/forms/public-forms S3
§13 Governance /docs/organizer/forms/privacy-and-retention S2
§14 Schema lifecycle /docs/organizer/forms/versioning-and-snapshots S2
§15 Workflows /docs/organizer/forms/reviews-and-delegation S4
§17.5 Webhooks /docs/organizer/forms/webhooks S5

29.3 Migration-specific docs

Before the data migration runs (end of S1), publish:

  • /docs/organizer/forms/migration-what-changes.md — user-facing changelog
  • /dev-docs/form-builder-migration-playbook.md — developer runbook

29.4 Copy catalogue as living document

/dev-docs/COPY_CATALOGUE.md is maintained across all sessions — see §30.

29.5 Quality gate

Docs reviewer checks:

  • No jargon without explanation
  • Screenshots of the UI where applicable (Cursor/Claude generates, Bert validates in manual verification)
  • "Als je vastloopt..." troubleshooting section
  • Link in in-app help menu (§28.5)

30. [v1.2] In-app copy catalogue (living seed)

A single source of truth for user-facing Dutch copy used in form-builder UI. Stored in /dev-docs/COPY_CATALOGUE.md and used as reference for frontend implementation.

Rationale: prevents inconsistent terminology ("Dienst" vs "Shift" vs "Taak") and centralises warnings/tooltips so they can be edited in one place.

30.1 Naming conventions

Concept Canonical Dutch term Never use
form_schema Formulier Schema, template
form_field Veld Vraag, item
form_template Formulier-sjabloon Template (alleen in dev-docs)
form_field_library Veldenbibliotheek Library, bibliotheek alleen
form_submission Inzending Submission, antwoord
is_filterable Filterbaar Queryable, zoekbaar
is_pii Bevat persoonsgegevens Privacy-gevoelig
freeze_on_submit Bevriezen na inzending Vergrendelen
consent_version Toestemmingsversie Consent-versie

30.2 Tooltip catalogue (selection)

is_filterable:
  "Filterbaar — dit veld wordt extra geïndexeerd voor snelle filtering
   in overzichten. Alleen aanvinken voor velden die je daadwerkelijk als
   filter gebruikt (bijvoorbeeld: shirtmaat wel, motivatie niet)."

is_pii:
  "Bevat persoonsgegevens — bij retentie-verwerking worden deze waardes
   geanonimiseerd volgens je privacy-instellingen. Vink aan voor velden
   zoals telefoon, e-mail, noodcontact, medische info."

is_unique:
  "Uniek per formulier — waardes van dit veld moeten uniek zijn over alle
   inzendingen heen. Geschikt voor bijvoorbeeld BSN of werknemersnummer.
   Dubbele waardes worden afgewezen."

freeze_on_submit:
  "Bevriezen na eerste inzending — zodra iemand het formulier indient
   kunnen de velden niet meer gewijzigd worden. Gebruik dit voor
   contracten, signatures, of formulieren waar de structuur vast moet
   staan voor audit-doeleinden."

snapshot_mode:
  never: "Geen snapshot — wijzigingen worden alleen in het activity log
         bijgehouden."
  on_submit: "Snapshot bij inzending — bij elke indiening wordt het
             complete formulier gesnapshot voor audit-doeleinden."
  always: "Altijd snapshot — elke wijziging (ook drafts) wordt
          gesnapshot. Gebruikt meer opslag maar biedt het volledige
          audit-spoor."

retention_days:
  "Bewaartermijn — na deze periode (vanaf inzendingsdatum) worden
   PII-velden automatisch geanonimiseerd. Typische waardes: 1095 dagen
   (3 jaar) voor vrijwilligers, 2555 dagen (7 jaar) voor contracten,
   null voor onbeperkt bewaren."

30.3 Warning catalogue (selection)

binding_change_with_submissions:
  "Je staat op het punt de koppeling van dit veld te wijzigen terwijl er
   al {count} ingediende inzendingen zijn. De historische waardes blijven
   bestaan, maar zijn niet meer de bron-van-waarheid. Dit kan niet
   ongedaan worden gemaakt."

delete_schema_with_submissions:
  "Dit formulier heeft {count} inzendingen. Als je het verwijdert, blijven
   de inzendingen bewaard als archief maar zijn niet meer nieuw in te
   dienen. Type de naam van het formulier om te bevestigen:"

field_type_change:
  "Je wijzigt het veldtype van {old} naar {new}. Bestaande waardes worden
   mogelijk niet correct omgezet — sommige kunnen onleesbaar worden.
   Aanbevolen: maak een nieuw veld aan in plaats van dit veld te
   wijzigen."

public_token_rotation:
  "Je roteert de publieke link voor dit formulier. Bestaande gebruikers
   kunnen nog 7 dagen inzenden met de oude link; daarna krijgen ze een
   410 Gone foutmelding."

30.4 Maintenance

Copy catalogue is updated in every session that adds new UI:

  • Check existing terms before creating new
  • Propose new terms to COPY_CATALOGUE.md
  • Bert reviews before merge

31. [v1.2] Integration contracts

The form builder does not operate in isolation. Other modules produce or consume form submissions, and these interactions must be contract-defined to prevent ad-hoc coupling.

31.1 Person Identity Matching integration

Trigger: FormSubmissionSubmitted event where form_schema.purpose == 'event_registration' and submission is public OR the submitter's user_id is not already linked to a person.

Listener: TriggerIdentityMatchOnRegistration

Contract:

Input: FormSubmission with subject_type=person (or null for public pre-
       submission).
Behaviour: Call PersonIdentityService::detectMatches($person) per
           existing logic. Service creates person_identity_matches rows
           with status=pending.
           Does NOT auto-confirm. Auto-linking is explicitly forbidden —
           organiser must confirm.
Output: No direct return. Side-effect: potential person_identity_matches
        rows.

Failure mode: if PersonIdentityService throws, listener logs at error level and does NOT fail the FormSubmission event propagation (other listeners continue).

31.2 GDPR delete workflow integration

Trigger: User account deletion (existing flow in UserController::destroy or dedicated GDPR-delete endpoint).

Contract:

Input: User about to be deleted.
Behaviour (in order):
  1. For every form_submission where subject_type='user' AND
     subject_id=that_user: call FormSubmissionAnonymisationService::anonymise
  2. For every form_submission where submitted_by_user_id=that_user
     AND subject_type != 'user' (i.e., user submitted for others or anonymous):
     clear submitted_by_user_id → null; copy user.name/email to
     public_submitter_name/public_submitter_email; set
     public_submitter_ip_anonymised_at = now().
  3. Delete user_profiles row (cascade via FK on user_id).
  4. Delete user_organisation_tags rows for this user.
  5. Anonymise activity_log entries where causer_id=that_user: replace
     causer_name with "[deleted-user]" but preserve log integrity.
Output: User fully disconnected from form-related data while audit trail
        preserved.

Transactional: all steps wrapped in DB transaction. If any step fails, rollback; user deletion is also rolled back.

GDPR export endpoint:

GET /api/v1/users/{user}/gdpr-export

Response: 200 OK with Content-Type: application/json
{
  "user": { ... core user attributes ... },
  "user_profile": { ... user_profiles row ... },
  "tags": [ ... user_organisation_tags ... ],
  "submissions": [
    {
      "schema": { "slug", "purpose", "organisation_name" },
      "values": { ... form_values keyed by field slug ... },
      "submitted_at": "...",
      "status": "..."
    }
  ]
}

Access: user themselves OR org_admin of any org the user is a member of. Format: JSON (v1). PDF export deferred.

31.3 Shift assignments integration

Trigger: FormSubmissionSubmitted where schema purpose is event_registration AND submission contains AVAILABILITY_PICKER field values.

Listener: CreateProvisionalShiftAssignmentsFromRegistration

Contract:

Input: FormSubmission with subject_type=person, AVAILABILITY_PICKER
       values (array of time_slot_id ULIDs), and optionally SECTION_PRIORITY
       values.
Behaviour:
  1. For each time_slot_id in AVAILABILITY_PICKER value:
     - Find shifts within that time_slot that match person's crowd_type
     - If person has section priorities: prefer shifts in priority sections
     - Create ShiftAssignment with status='claim_pending'
     - Do NOT auto-confirm; requires organiser approval
  2. For each SECTION_PRIORITY value: upsert row in
     person_section_preferences (existing table).
Output: ShiftAssignments in claim_pending status. Organiser sees them in
        the standard shift-assignments approval queue.

Cancellation flow: when a person submits an absence_report form (§3.2.13), the listener HandleAbsenceReport flips affected ShiftAssignment.status → cancelled with cancellation_source='volunteer_absence'.

31.4 Email notifications integration

Form-builder uses the existing CrewliMailable + email-template infrastructure (built April 2026).

Mailables used:

Event Mailable Template
FormSubmissionSubmitted (event_registration) RegistrationConfirmation registration_confirmation
FormSubmissionReviewed (approved) SubmissionApproved submission_approved
FormSubmissionReviewed (rejected/changes_requested) SubmissionNeedsAttention submission_needs_attention
FormSubmissionSubmitted (pending review) ReviewPending review_pending
Retention job run RetentionReport (aggregated, daily) retention_report
Incident report (kritiek ernst) CriticalIncidentAlert critical_incident_alert
Post-event evaluation trigger EvaluationInvitation evaluation_invitation

Contract:

All listeners use Mail::queue (not Mail::send) to avoid blocking the
event dispatch. Mailables respect org-level branding config (logo,
primary_color, sender_name, reply_to, footer_text).

Template override: orgs can override default templates via existing org-level email-template management UI.

31.5 Code-of-conduct gating integration

Trigger: any shift-claim attempt (existing endpoint POST /portal/shifts/{shift}/claim).

Check: before accepting the claim, verify:

Does a form_submission exist where
  form_schema.purpose = 'signature_code_of_conduct' AND
  subject_type = 'person' AND
  subject_id = claimer_person_id AND
  status = 'submitted' AND
  form_schema.organisation_id = current_organisation_id ?

If NO: reject claim with 422 "Je moet eerst de gedragscode tekenen". Response includes link to the code-of-conduct form URL.

If YES: accept claim.

Implementation: in ShiftClaimService::canClaim().

31.6 Supplier intake / production_requests integration

Trigger: organiser creates a production_requests row (existing infrastructure) with company_id and selects a form_schema of purpose supplier_intake.

Behaviour:

  • form_schema's public_token is created/reused
  • production_request.token is generated (existing logic)
  • Supplier receives email with URL combining both tokens: {APP_URL}/f/{public_token}?pr={production_request.token}
  • When supplier opens URL: both tokens validated, submission created with subject_type=company, subject_id=production_request.company_id

31.7 Accreditation engine integration (ARCH-07, future)

Reserved contract for when accreditation engine lands:

  • When signature_receipt submissions are created: attached accreditation items from ARCH-07's accreditation_items are listed in the submission as pre-filled TABLE_ROWS values
  • When check_out_inventory submissions are reviewed as complete: accreditation_item.returned_at is updated per item

Exact contract TBD when ARCH-07 implementation starts. v1.2 leaves the hooks in place (purpose enum values exist, no code integration yet).

31.8 Crowd list integration

Trigger: FormSubmissionSubmitted where purpose=event_registration AND submission results in a new Person being created.

Listener: AddPersonToApplicableCrowdListsOnRegistration

Contract:

Input: FormSubmission with subject_type=person (new person).
Behaviour:
  1. Fetch crowd_lists where organisation_id matches AND
     crowd_list.auto_add_criteria matches the new person (e.g.,
     crowd_type matches).
  2. For each matching crowd_list: add person via existing
     CrowdListService::addPerson.
  3. Crowd_lists without auto_add_criteria are ignored (manual
     management).
Output: person_crowd_list pivot rows for applicable lists.

Crewli Context: Crowd lists are optional organizational tools, not mandatory gatekeepers. Not every registration needs a crowd list membership.

31.9 Integration contract tests

Every contract in §31.1-31.8 has a corresponding integration test in tests/Feature/FormBuilder/Integration/:

  • IdentityMatchTriggerTest
  • GdprDeleteCascadeTest
  • ShiftAssignmentFromRegistrationTest
  • EmailNotificationFlowTest
  • CodeOfConductGatingTest
  • SupplierIntakeFlowTest
  • CrowdListAutoAddTest

Tests are part of CI. Contract changes require test updates (and this ARCH section update) before merge.

31.10 Tag sync integration (BACKLOG FORM-02)

Replaces the S1-era TagSyncService that read the legacy person_field_values table. Purged in S2a; rebuilt in S2b against the new FormBuilder.

Contract (authoritative):

Trigger: FormSubmissionSubmitted event where
         form_schema.purpose = 'event_registration' AND
         submission.subject_type = 'person' AND
         submission contains at least one TAG_PICKER form_value.

Listener: SyncTagPickerSelectionsOnSubmit

Behaviour:
  1. Resolve the Person from submission.subject_id.
  2. If person.user_id is null → no-op (log at info, tag sync will
     trigger on future identity link).
  3. Call FormTagSyncService::rebuildForPerson($person).
  4. Never mutates organiser_assigned tags. Only rebuilds
     source = self_reported to match the union of TAG_PICKER values
     across that person's submitted event_registration submissions.

Re-trigger on identity link: PersonIdentityService::confirmMatch must,
after setting person.user_id, call FormTagSyncService::rebuildForPerson
($person). This is the deferred sync path for persons who filled in
TAG_PICKER fields before their user account was linked.

Failure mode: listener logs at error level and does NOT fail the event
propagation (other listeners — e.g. §31.3 shift provisioning, §31.1
identity matching, §31.8 crowd list auto-add — must still run).

Idempotent: safe to run multiple times for the same person.

Call site removed in S2a: PersonController::approve() and PersonIdentityService::syncRegistrationTags() used to call TagSyncService::syncFromRegistration($person) directly. The rebuilt flow is listener-driven plus the targeted call inside PersonIdentityService::confirmMatch — no ad-hoc cross-module coupling.


End of ARCH v1.2

Refactor completion checklist (updated at end of S6):

  • All migrations applied in dev
  • All 431 existing tests green + ≥200 new tests
  • Migration rehearsal passed
  • Real migration run successful
  • All 22 FormPurposes seed-tested
  • All integration contracts (§31) tested
  • VitePress documentation complete per §29
  • SCHEMA.md updated to reflect final state
  • Pre-flight audit removed from session prompts
  • This document marked "refactor complete"

When complete: add a "Refactor complete — effective {DATE}" banner at top of this document. Pre-flight audit gate (§21) becomes optional.