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 — Developer Getting Started
Onboarding for developers writing code against the new form-builder tables. Companion to
/dev-docs/ARCH-FORM-BUILDER.mdv1.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:
-
Morph map (
app/Providers/AppServiceProvider.php):Relation::enforceMorphMap([ // …existing entries… 'artist' => \App\Models\Artist::class, ]); -
Subject registry (
config/form_subjects.php):'artist' => [ 'model' => \App\Models\Artist::class, 'display_attribute' => 'name', 'permission_check' => \App\Policies\ArtistPolicy::class.'@view', ], -
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
FilterQueryBuilderinterface (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.