SCHEMA.md - New §3.5.12 "Form Builder" with the legacy-tables-retained note placed prominently directly under the section header (per S1 wrap-up Path 3 decision: Phase 8 deferred to S2). - Crosswalk: every legacy volunteer_profiles column → its new home (user_profiles columns vs form_fields vs person_tags). - Summary table for the 13 new tables with one-line purpose + ARCH § pointer each. - Activity log strategy and multi-tenancy discipline noted. - §3.5.4 marked SUPERSEDED with a pointer to the new section. /dev-docs/form-builder-migration-playbook.md (new) - Operator runbook for forms:migrate-legacy-data on real legacy data. - Pre-flight audit, dry-run, migrate, verify, spot-check, rollback paths spelled out. Same legacy-tables-retained note prominently. /dev-docs/form-builder-getting-started.md (new) - Developer onboarding. Mental model, code samples for creating a schema/field/submission/value, adding a new subject type, registering a custom field type, suppressing activity log via App\Support\ActivityLog::suppressed. /dev-docs/COPY_CATALOGUE.md (new) - Seeded verbatim from ARCH §30 (naming conventions, tooltip catalogue, warning catalogue) with a header explaining purpose, growth strategy, and the per-PR update workflow. /docs/organizer/forms/concepts/wat-is-een-formulier.md (new VitePress) - Dutch, informal je/jij. Follows /docs/.templates/concept-page.md. - Three example use-cases: vrijwilligersregistratie, artist advance, incidentrapportage. Light foundation; depth arrives in S2-S5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.0 KiB
Form Builder — Migration Playbook
Operator runbook for migrating legacy
registration_form_fields/person_field_values/registration_field_templatesdata into the newform_*structure. Companion to/dev-docs/ARCH-FORM-BUILDER.md§11.
Status (post-S1)
Legacy tables retained intentionally. The tables registration_form_fields,
person_field_values, and registration_field_templates remain in the
schema through end of S1. They are dropped atomically in the first S2
commit together with the removal of legacy controllers, services, requests,
resources, policies, and routes. DevSeeder and FormBuilderDevSeeder no
longer write to them; they hold zero rows in dev but the schema is preserved
for environments with real legacy data that will be migrated via
forms:migrate-legacy-data.
Until S2 lands, the legacy registration UI remains functional for any environment that depends on it.
When to use this playbook
Run this when promoting an environment that holds real legacy
registration data into the new form-builder schema. For development
machines starting from migrate:fresh --seed, no migration is needed —
DevSeeder produces new-structure data directly.
Pre-flight audit
Before touching any data, confirm the working state matches expectations.
# 1. Working tree clean?
git status
# 2. On the expected branch?
git log -1 --oneline
# 3. Database backup exists? (production / staging only — your call for dev)
# 4. Verify the new tables are present and the legacy tables still hold data
php artisan db:show | grep -E "form_|registration_form_fields|person_field_values|registration_field_templates"
If the legacy tables are absent, the migration command will detect this and exit cleanly without doing work — there is nothing to migrate.
Step 1 — Dry run
Always start with a dry run. It writes nothing and prints the same summary table the real run would produce.
php artisan forms:migrate-legacy-data --dry-run
What to look for:
- The per-organisation log lines list every event that will get a new schema and the number of fields/submissions per event.
- The summary table shows projected counts for
form_schemas,form_fields,form_submissions,form_values,form_templates. - Exit code is
0.
If anything looks surprising — wildly off counts, organisations missing, templates duplicated — STOP and investigate. Do not proceed to step 2 until the dry-run output is what you expect.
Step 2 — Run the migration
php artisan forms:migrate-legacy-data
What happens:
- Per organisation, inside a transaction (one bad org rolls back only itself).
- Per event in that org: one
form_schemasrow + Nform_fieldsrows. - Per person with
person_field_valuesfor that event: oneform_submissionsrow (status =submittedif person.status ∈ applied/approved/no_show elsedraft) + oneform_valuesrow per legacy value. FormValueObserverpopulates typed columns and theform_value_optionspivot during the inserts (per ARCH §7.2).- All
registration_field_templatesrows are wrapped in the ARCH §4.6.1schema_snapshotshape and inserted asform_templates. - The whole run is wrapped in
App\Support\ActivityLog::suppressed(...)so the activity log isn't flooded by hundreds oflogSchemaChange/logFieldChangeentries from the bulk inserts. - After migration completes, verification runs automatically. Final
exit code is whatever
forms:verify-data-integrityreturns.
Idempotent: if a form_schemas row already exists for (org, event, event_registration), that event is skipped silently. Safe to re-run.
Step 3 — Verify
Verification runs automatically at the end of step 2. To re-run it standalone (or to verify state without touching data):
php artisan forms:verify-data-integrity
Or, equivalently:
php artisan forms:migrate-legacy-data --verify-only
Nine checks:
- Schema coherence (purpose enum, custom_purpose_slug ⇄ purpose=custom, public_token uniqueness)
- Field coherence (FK valid, field_type ∈ FormFieldType ∪ custom registry, is_filterable matches type, slug unique within schema, binding JSON valid against
config/form_binding.php) - Submission coherence (subject_type/subject_id consistency, subject_type ∈
config/form_subjects.php, status ⇄ submitted_at) - Value coherence (orphans, unique pair, length, multi-value sanity, value_indexed only set when field.is_filterable)
- User profile coherence (every user has one profile; reliability_score ∈ [0, 5])
- Data migration counts (skipped when legacy tables absent)
- Orphan records (pivot rows, section statuses)
- Section/schema relation consistency
- (
--strict) Strict reachability: value.field.schema must equal value.submission.schema
Exit code 0 means all checks passed. Anything else is a real failure
worth investigating before proceeding.
Step 4 — Spot-check a sample
After verification is green, eyeball a sample submission to confirm the shape is right.
php artisan tinker
>>> use App\Models\FormBuilder\FormSubmission;
>>> $sub = FormSubmission::with(['schema', 'values.field'])->latest()->first();
>>> $sub->schema->name;
>>> $sub->values->map(fn($v) => [$v->field->slug => $v->value]);
Compare against the legacy view of the same person's data in
person_field_values to confirm nothing was silently lost.
Rollback
The data-migration command does not have a down() step. If a migration
went wrong, the recovery path is:
- Restore from backup (production / staging).
- For dev:
php artisan migrate:fresh --seedrebuilds everything from scratch.
The legacy tables are preserved during the migration — they are not
modified. So even after running forms:migrate-legacy-data, the legacy
data is still there (until S2 drops the tables). This means a production
data-migration error can be recovered by truncating the new form_* tables
and re-running with the underlying legacy data still intact.
Common gotchas
registration_form_fieldstable absent: the command detects this and exits 0 silently. Expected behaviour for fresh environments.- PII heuristic miss: the heuristic in §11.2.1 of the ARCH covers
obvious patterns (email, phone, geboort, allergie, …) but is not
exhaustive. After migration, organisers can fine-tune
is_piiper field via the form-builder UI (S5). is_publishedderived from event status: the command considers an event "published" when its status is one of registration_open / buildup / showday / teardown / closed. If the legacy registration was open on events with other statuses, adjustis_publishedmanually after migration.- Custom field types: the command maps the 10 legacy field types
(text/textarea/select/multiselect/checkbox/radio/boolean/number/tag_picker/heading)
into the new uppercase enum values. If your legacy data has values
outside this set, the command will set them to
TEXTand the verifier will flag them as invalidfield_type.