id, 'FormBindingApplicator must be invoked inside DB::transaction', ); } /** @var \App\Models\FormBuilder\FormSchema|null $schema */ $schema = $submission->schema; if ($schema === null) { throw new FormBindingApplicatorException( 'no_schema', (string) $submission->id, ); } $purposeValue = $schema->purpose->value; if (! $this->purposeRegistry->has($purposeValue)) { throw new FormBindingApplicatorException( 'unknown_purpose', (string) $submission->id, "purpose '{$purposeValue}' not registered", ); } $resolver = $this->purposeRegistry->subjectResolverFor($purposeValue); $subject = $resolver->resolveOrProvision($submission); if (! $subject instanceof Model) { // Anonymous-allowed (incident_report). No bindings to apply. $result = new BindingPassResult( formSubmissionId: (string) $submission->id, provisionedSubjectType: null, provisionedSubjectId: null, applications: [], ); $this->activityLogger->logPass($submission, $result); return $result; } $resolved = $this->conflictResolver->resolve($submission, $sectionId); // Persist subject identity for the result + apply each binding. $applications = []; foreach ($resolved as $binding) { // Skip identity-key bindings — the resolver already used them // for subject lookup in EventRegistration's PersonProvisioner // path. Writing them again is a no-op at best, a clobber at // worst. if ($binding->isIdentityKey) { continue; } $applications[] = $this->applyOne($subject, $binding); } $result = new BindingPassResult( formSubmissionId: (string) $submission->id, provisionedSubjectType: $this->morphAlias($subject), provisionedSubjectId: (string) $subject->getKey(), applications: $applications, ); $this->activityLogger->logPass($submission, $result); return $result; } private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult { try { // Defensive: BindingTypeRegistry validates Append-against-scalar // at publish time; runtime check is a failsafe for live-table // edits between publish and apply. $this->typeRegistry->validateAppendStrategy( $binding->targetEntity, $binding->targetAttribute, $binding->mergeStrategy, ); $oldValue = $subject->getAttribute($binding->targetAttribute); $newValue = $this->computeNewValue($oldValue, $binding); if ($newValue === self::NO_OP) { return BindingApplicationResult::succeeded( bindingId: $binding->bindingId, targetEntity: $binding->targetEntity, targetAttribute: $binding->targetAttribute, oldValue: $oldValue, newValue: $oldValue, ); } $subject->setAttribute($binding->targetAttribute, $newValue); $subject->save(); return BindingApplicationResult::succeeded( bindingId: $binding->bindingId, targetEntity: $binding->targetEntity, targetAttribute: $binding->targetAttribute, oldValue: $oldValue, newValue: $newValue, ); } catch (Throwable $e) { return BindingApplicationResult::failed( bindingId: $binding->bindingId, targetEntity: $binding->targetEntity, targetAttribute: $binding->targetAttribute, e: $e, ); } } private const NO_OP = '__binding_noop_sentinel__'; private function computeNewValue(mixed $oldValue, ResolvedBinding $binding): mixed { $newValue = $binding->value; $strategy = $binding->mergeStrategy; // Per-strategy matrix. RFC §3 Q7. if ($newValue === null) { $behaviour = $strategy->nullWinnerBehaviour(); return match ($behaviour) { 'write' => null, 'noop' => self::NO_OP, 'conditional' => $oldValue === null ? null : self::NO_OP, default => self::NO_OP, }; } return match ($strategy) { FormFieldBindingMergeStrategy::Overwrite => $newValue, FormFieldBindingMergeStrategy::Append => $this->appendCollection($oldValue, $newValue, $binding), FormFieldBindingMergeStrategy::Replace => $oldValue === null ? $newValue : self::NO_OP, FormFieldBindingMergeStrategy::FirstWriteWins => $oldValue === null ? $newValue : self::NO_OP, }; } private function appendCollection(mixed $oldValue, mixed $newValue, ResolvedBinding $binding): mixed { if ($binding->targetType !== BindingTargetType::COLLECTION) { // Defensive — publish guard should prevent this. Throwing // gets the failure into BindingApplicationResult::failed. throw new \InvalidArgumentException( "merge_strategy=append requires COLLECTION target; got {$binding->targetType->value}", ); } $current = is_array($oldValue) ? $oldValue : []; $incoming = is_array($newValue) ? $newValue : [$newValue]; // Set semantics: dedupe via array_unique. Preserves insertion order // for stable activity log output. $merged = array_values(array_unique(array_merge($current, $incoming), SORT_REGULAR)); return $merged; } private function morphAlias(Model $subject): string { return $subject->getMorphClass(); } }