Files
crewli/dev-docs/ARCH-FORM-BUILDER.md
bert.hausmans e7c9482474 refactor(form-field): drop form_fields.options + form_field_library.options
Final WS-5d cleanup. The JSON columns that have been unread since
commit 3 are now physically dropped on both source tables. Their
canonical rich-shape lives in form_field_options, accessed
exclusively through the morphMany relation.

Defensive sweep: any lingering translations.{locale}.options key in
either source table's translations bag is stripped. Commit 2's
backfill should already have done so exhaustively; this is
belt-and-braces.

Rollback re-creates the columns as nullable JSON but leaves them
empty. Pair with commit 2's rollback to restore the pre-WS-5d data
shape on every owner row.

The commit-3 getOptionsAttribute accessor-bridge on FormField +
FormFieldLibrary is removed — Eloquent's getAttribute() resolution
now naturally falls through to the morphMany relation since there's
no underlying column to shadow it. New regression test
FormFieldOptionsAccessTest asserts $field->options resolves to an
Eloquent Collection of FormFieldOption instances and lazy-loads in
exactly 2 queries (1 parent + 1 lazy-load options) on a fresh fetch
without with() preload. Same trio for FormFieldLibrary.

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new drop_form_field_options_json_columns migration on the
rollback stack.

Documentation:
  - SCHEMA.md v2.6: form_field_options table documented; options row
    removed from form_fields and form_field_library; morphMany
    relations updated; cross-references to ARCH-FORM-BUILDER §17.6
    and addendum §Q3 WS-5d Uitvoering added on both source-table
    docblocks.
  - ARCH-FORM-BUILDER.md v1.8: new §17.6 "Field options (relational)"
    mirrors the §17.4 / §17.5 relational-sibling structure with
    sub-sections 17.6.1 rationale, 17.6.2 table + catalogue, 17.6.3
    service / scope / cascade / activity log, 17.6.4 snapshot
    embedding, 17.6.5 external API contract. Existing Webhooks
    section renumbered from §17.6 to §17.7.
  - ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md: "Uitvoering — WS-5d
    (2026-04-27)" section added. Eight paragraphs covering the
    snapshot atomic rewrite, strict-fail backfill dispatch, dual
    activity-log emit, four-sibling base-class extraction warrant,
    commit 0 dead-code precondition, the temporary getOptionsAttribute
    accessor-bridge pattern (with reusability note for future
    JSON→relational refactors), the dev-seeder vergoedingstype RADIO
    normalisation (drift correction explicitly distinguished from the
    parallel apps/app RegistrationFieldTemplate description domain),
    and the WS-5 family completion note.
  - BACKLOG.md: FORM-BUILDER-LIBRARY-AUDIT-LOG entry extended to four
    services (adds library.options_replaced); new
    FORM-BUILDER-MORPH-SCOPE-BASE-CLASS entry added as the WS-5d
    follow-up now that all four concrete morph-scope siblings exist.

Tests: 1193 → 1208 green (+15 across commits 3+4+5; this commit alone:
+2 from the regression test).

This completes the WS-5 family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 03:00:20 +02:00

3881 lines
156 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ARCH — Universal Form Builder (v1.8)
> **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.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).
> **Previous:**
> 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:
11. **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.
12. **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.
13. **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_requirements``form_fields` per event
- `first_aid`, `driving_licence``person_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:** artist via portal_token (no authentication required).
**Submission flow:**
1. Artist receives email with portal link (triggered by organiser)
2. Opens link → token resolved, submission fetched or created
3. Form shown with sections: General Info, Contacts, Production (power,
catering, transport), Technical Rider, Hospitality
4. Artist fills sections independently; each section submit 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 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:
```json
{
"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).
### 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_at``max(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`:
```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`:
```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`:
```json
// 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)`.
**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:
```json
{
"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`:
```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:
```json
{
"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:
```json
{
"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:
```json
"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`:
```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:
```json
{ "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}/sections` — `show_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_fields` → `form_fields`; add form_schema_id
(nullable), keep event_id temporarily
3. Rename: `person_field_values` → `form_values`; add form_submission_id
(nullable), keep person_id + registration_form_field_id temporarily
4. Rename: `registration_field_templates` → `form_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:
```php
$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.
### 13.5 Consent tracking
`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:
```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:
```php
// config/form_builder.php
'custom_field_types' => [
'rich_text' => \App\FormFieldTypes\RichTextFieldHandler::class,
// ...
],
```
Each handler implements `CustomFieldTypeHandler` interface:
```php
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` |
| `post_event_evaluation` | Evaluatie na afloop | person | single | no | — |
| `incident_report` | Incident-melding | person | multiple | no | — |
| `signature_contract` | Contract-ondertekening | user | single | no | — |
| `user_profile` | Profiel-update | user | single | no | — |
`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):**
- `required` — `form_fields.is_required` column is the single source of
truth. Any legacy `validation_rules.required` JSON is WARN-logged and
skipped at backfill.
- `unique` — `form_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)`.
Base-class extraction between the two scope classes is deliberately
deferred to WS-5d per addendum Q3 — premature abstraction from two
siblings is still premature, and WS-5d's `form_field_options` /
WS-5c's `form_field_conditional_logic` may surface a different shared
shape.
**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`:
```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`) — third near-duplicate of
`FormFieldBindingScope`. The three siblings' base-class extraction is
deferred to WS-5d per addendum Q3 (abstracting from three is still
premature when the fourth sibling is about to land and may clarify
what truly varies).
- **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`:
```json
{
"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
materialises the four concrete morph-scope implementations and
unblocks the deliberate follow-up of base-class extraction.
#### 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 the fourth concrete UNION-over-two-owner-
chains sibling, near-duplicate of the binding / validation-rules /
configs scopes. Base-class extraction across the four siblings is
deliberately deferred to a follow-up work package now that the four
implementations exist and the "what actually varies" question can be
answered empirically.
`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:
```json
[
{"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
```json
{
"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:
```bash
# 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`:
```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:
```php
// config/queue.php — add
'connections' => [
'webhooks' => [
'driver' => 'redis',
'queue' => 'webhooks',
'retry_after' => 120,
'block_for' => null,
],
],
```
Jobs and their queues:
- `DeliverFormWebhookJob` → `webhooks` queue (dedicated, can be throttled
separately)
- `FormSubmissionRetentionJob` → `default` queue (daily schedule)
- `BackfillFormValueIndexedJob` → `default` queue (on is_filterable toggle)
- `RebuildSearchIndexJob` → `default` 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)
### 23.2 Universal text search
`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:
```json
{
"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
```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
```php
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`
### 28.5 In-app documentation links
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.