Files
crewli/dev-docs/form-builder-getting-started.md
bert.hausmans cfc7610497 docs(forms): SCHEMA crosswalk, foundation concept page, getting-started + migration playbook, copy catalogue init
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>
2026-04-17 17:06:53 +02:00

7.0 KiB

Form Builder — Developer Getting Started

Onboarding for developers writing code against the new form-builder tables. Companion to /dev-docs/ARCH-FORM-BUILDER.md v1.2 (the authoritative spec) and /dev-docs/SCHEMA.md §3.5.12.


Prerequisites

  • Laravel 12 / PHP 8.2+.
  • Familiarity with Eloquent, ULIDs, polymorphic relationships.
  • Read ARCH §0 (TL;DR) and §3 (FormPurpose catalogue).

The mental model in one paragraph

A FormSchema is a form definition. FormFields belong to it. A FormSubmission is one filled-in instance of a schema, identified by its polymorphic subject (a Person, User, Company, Organisation, Event, or nothing for public forms). FormValues are the EAV row per (submission, field) pair, JSON payload + typed columns populated by an observer based on the field's storage hint and filterability. Every schema declares its purpose (event_registration, incident_report, …) which constrains lifecycle and integrations.


Creating a new schema with two fields

use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;

$event = Event::find($eventId);

$schema = FormSchema::create([
    'organisation_id' => $event->organisation_id,
    'owner_type' => 'event',
    'owner_id' => $event->id,
    'name' => "{$event->name} — registratie",
    'slug' => "{$event->slug}-registratie",
    'purpose' => FormPurpose::EVENT_REGISTRATION,
    'submission_mode' => FormSubmissionMode::DRAFT_SINGLE,
    'is_published' => false,
    'locale' => 'nl',
]);

FormField::create([
    'form_schema_id' => $schema->id,
    'field_type' => FormFieldType::SELECT->value,
    'slug' => 'shirtmaat',
    'label' => 'Shirtmaat',
    'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
    'is_required' => true,
    'is_filterable' => true,
    'value_storage_hint' => FormFieldType::SELECT->recommendedValueStorageHint(),
    'sort_order' => 1,
]);

FormField::create([
    'form_schema_id' => $schema->id,
    'field_type' => FormFieldType::TEXTAREA->value,
    'slug' => 'opmerkingen',
    'label' => 'Opmerkingen',
    'value_storage_hint' => FormFieldType::TEXTAREA->recommendedValueStorageHint(),
    'sort_order' => 2,
]);

Then to log a meaningful change:

$schema->is_published = true;
$schema->save();
$schema->logSchemaChange('schema.published');

Trivial label/help_text edits should NOT call logSchemaChange — see ARCH §17.1 for the curated event list.


Creating a submission and its values

use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormValue;

$person = \App\Models\Person::find($personId);

$submission = FormSubmission::create([
    'form_schema_id' => $schema->id,
    'subject_type' => 'person',
    'subject_id' => $person->id,
    'submitted_by_user_id' => $person->user_id,
    'status' => FormSubmissionStatus::SUBMITTED,
    'submitted_at' => now(),
    'is_test' => false,
]);

// One value per field. The shape of `value` is JSON-wrapped per field type.
FormValue::create([
    'form_submission_id' => $submission->id,
    'form_field_id' => $shirtmaatField->id,
    'value' => ['value' => 'L'],
]);

FormValue::create([
    'form_submission_id' => $submission->id,
    'form_field_id' => $opmerkingenField->id,
    'value' => ['value' => 'Graag een nachtshift'],
]);

FormValueObserver automatically populates value_indexed for filterable single-value fields, value_number for NUMBER hint, value_date for DATE hint, value_bool for BOOL hint, and the form_value_options pivot for multi-value filterable fields (MULTISELECT / CHECKBOX_LIST / TAG_PICKER). You don't write to those columns directly.


Adding a new subject type

Three places to update — keep them in sync:

  1. Morph map (app/Providers/AppServiceProvider.php):

    Relation::enforceMorphMap([
        // …existing entries…
        'artist' => \App\Models\Artist::class,
    ]);
    
  2. Subject registry (config/form_subjects.php):

    'artist' => [
        'model' => \App\Models\Artist::class,
        'display_attribute' => 'name',
        'permission_check' => \App\Policies\ArtistPolicy::class.'@view',
    ],
    
  3. Reverse relation on the model itself:

    public function formSubmissions(): MorphMany
    {
        return $this->morphMany(\App\Models\FormBuilder\FormSubmission::class, 'subject');
    }
    

The verifier (forms:verify-data-integrity) cross-checks subject_type against the config/form_subjects.php keys; an unknown subject_type will fail the submission-coherence check.


Registering a custom field type

The field_type column on form_fields is a string, not a DB enum, so custom types can be added at runtime via configuration. Under the hood this is the CustomFieldTypeRegistry planned for ARCH §17.2 — for now, register the type's value in config/form_builder.php:

'custom_field_types' => [
    'COLOR_PICKER' => [
        'label' => 'Kleurkeuze',
        'storage_hint' => 'string',
        'filterable' => true,
    ],
],

The verifier accepts any field_type that's either in FormFieldType::values() or in config('form_builder.custom_field_types'). Frontend rendering and validation handlers come in S6 when the registry interface lands.


Suppressing activity log during bulk operations

logSchemaChange and logFieldChange produce one activity-log row per call. In bulk fixture runs (DevSeeder, the data-migration command, import scripts) this floods activity_log with hundreds of rows that provide no audit value. Wrap the bulk operation:

use App\Support\ActivityLog;

ActivityLog::suppressed(function () use ($schemas): void {
    foreach ($schemas as $schemaData) {
        $schema = FormSchema::create($schemaData);
        // logSchemaChange calls inside this closure are silent no-ops.
    }
});

The helper flips config('activitylog.enabled') for the duration of the callback and restores it in finally. Both our explicit calls AND the spatie LogsActivity trait (used on Organisation elsewhere) respect the flag via ActivityLogger::log().

Do NOT use this in regular request paths — the activity log is the audit trail; suppressing it silently is an antipattern outside of fixtures and one-shot commands.


Where to look next

  • ARCH-FORM-BUILDER.md §4 — full column specs for every table.
  • ARCH-FORM-BUILDER.md §6 — binding patterns (entity-owned / form-owned / mirrored).
  • ARCH-FORM-BUILDER.md §7 — filter architecture and the FilterQueryBuilder interface (S4).
  • config/form_binding.php — Entity Column Registry (Pattern A/C binding targets).
  • config/form_subjects.php — Subject Type Registry.
  • config/form_builder.php — limits, webhook policy, captcha, retention, feature flags.
  • forms:migrate-legacy-data + forms:verify-data-integrity — see the migration playbook in this directory for the operator's view.