create(); $schema = FormSchema::factory()->create([ 'organisation_id' => $org->id, 'purpose' => FormPurpose::EVENT_REGISTRATION, 'snapshot_mode' => 'on_submit', 'is_published' => true, 'public_token' => (string) \Illuminate\Support\Str::ulid(), ]); // Email field with entity binding + validation rule. FormField::factory() ->withValidationRule(FormFieldValidationRuleType::MaxLength, ['value' => 100]) ->withEntityBinding('person', 'email') ->create([ 'form_schema_id' => $schema->id, 'field_type' => FormFieldType::EMAIL->value, 'slug' => 'contact_email', 'label' => 'E-mail', ]); // Number field with min/max validation + conditional logic. FormField::factory() ->withValidationRule(FormFieldValidationRuleType::MinValue, ['value' => 18]) ->withValidationRule(FormFieldValidationRuleType::MaxValue, ['value' => 99]) ->withConditionalLogic([ 'operator' => 'all', 'children' => [ ['field_slug' => 'contact_email', 'operator' => 'not_empty'], ], ]) ->create([ 'form_schema_id' => $schema->id, 'field_type' => FormFieldType::NUMBER->value, 'slug' => 'leeftijd', 'label' => 'Leeftijd', ]); // Select field with options + translations. FormField::factory() ->withOptions(['XS', 'S', 'M', 'L']) ->create([ 'form_schema_id' => $schema->id, 'field_type' => FormFieldType::SELECT->value, 'slug' => 'shirtmaat', 'label' => 'Shirtmaat', ]); // Submit so schema_snapshot materializes. $service = resolve(FormSubmissionService::class); $draft = $service->createDraft($schema, null, null, []); $service->submit($draft, null); // First read: through Eloquent cast (decode → assoc array). $first = FormSubmission::query()->withoutGlobalScopes()->findOrFail($draft->id); $snapshotA = $first->schema_snapshot; // Second read: a fresh model instance (no cached attributes). $second = FormSubmission::query()->withoutGlobalScopes()->findOrFail($draft->id); $snapshotB = $second->schema_snapshot; // Third read: raw column bytes via the query builder, decoded once. $rawJson = (string) DB::table('form_submissions') ->where('id', $draft->id) ->value('schema_snapshot'); $snapshotC = json_decode($rawJson, true); // All three roads must produce byte-identical canonical JSON. $this->assertSame( JsonCanonicalizer::encode($snapshotA), JsonCanonicalizer::encode($snapshotB), ); $this->assertSame( JsonCanonicalizer::encode($snapshotA), JsonCanonicalizer::encode($snapshotC), ); // And the canonical encode of every JSON-bearing nested fragment // must be byte-identical too — covers each field's options / // validation_rules / configs / bindings / conditional_logic // in one assertion via the whole-snapshot canonical encode. $this->assertNotEmpty($snapshotA['fields']); foreach ($snapshotA['fields'] as $idx => $fieldA) { $fieldC = $snapshotC['fields'][$idx]; $this->assertSame( JsonCanonicalizer::encode($fieldA), JsonCanonicalizer::encode($fieldC), "field #{$idx} ({$fieldA['slug']}) drifted across reads", ); } } }